diff --git a/api/http/handler/endpoints/endpoint_force_update_service.go b/api/http/handler/endpoints/endpoint_force_update_service.go new file mode 100644 index 000000000..4735c2f32 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_force_update_service.go @@ -0,0 +1,108 @@ +package endpoints + +import ( + "context" + "net/http" + "strings" + + portaineree "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/docker/consts" + "github.com/portainer/portainer/api/docker/images" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/docker/docker/api/types" + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" +) + +type forceUpdateServicePayload struct { + // ServiceId to update + ServiceID string + // PullImage if true will pull the image + PullImage bool +} + +func (payload *forceUpdateServicePayload) Validate(r *http.Request) error { + return nil +} + +// @id endpointForceUpdateService +// @summary force update a docker service +// @description force update a docker service +// @description **Access policy**: authenticated +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @accept json +// @produce json +// @param id path int true "endpoint identifier" +// @param body body forceUpdateServicePayload true "details" +// @success 200 {object} dockertypes.ServiceUpdateResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "endpoint not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/forceupdateservice [put] +func (handler *Handler) endpointForceUpdateService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid environment identifier route variable", err) + } + + var payload forceUpdateServicePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return httperror.BadRequest("Invalid request payload", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portaineree.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return httperror.Forbidden("Permission denied to force update service", err) + } + + dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil) + if err != nil { + return httperror.InternalServerError("Error creating docker client", err) + } + defer dockerClient.Close() + + service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), payload.ServiceID, dockertypes.ServiceInspectOptions{InsertDefaults: true}) + if err != nil { + return httperror.InternalServerError("Error looking up service", err) + } + + service.Spec.TaskTemplate.ForceUpdate++ + + if payload.PullImage { + service.Spec.TaskTemplate.ContainerSpec.Image = strings.Split(service.Spec.TaskTemplate.ContainerSpec.Image, "@sha")[0] + } + + newService, err := dockerClient.ServiceUpdate(context.Background(), payload.ServiceID, service.Version, service.Spec, dockertypes.ServiceUpdateOptions{QueryRegistry: true}) + if err != nil { + return httperror.InternalServerError("Error force update service", err) + } + + go func() { + images.EvictImageStatus(payload.ServiceID) + images.EvictImageStatus(service.Spec.Labels[consts.SwarmStackNameLabel]) + containers, _ := dockerClient.ContainerList(context.TODO(), types.ContainerListOptions{ + All: true, + Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+payload.ServiceID)), + }) + + for _, container := range containers { + images.EvictImageStatus(container.ID) + } + }() + + return response.JSON(w, newService) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 59fb8f474..23bff58af 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -6,6 +6,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/demo" + dockerclient "github.com/portainer/portainer/api/docker/client" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" @@ -36,6 +37,7 @@ type Handler struct { K8sClientFactory *cli.ClientFactory ComposeStackManager portainer.ComposeStackManager AuthorizationService *authorization.Service + DockerClientFactory *dockerclient.ClientFactory BindAddress string BindAddressHTTPS string PendingActionsService *pendingactions.PendingActionsService @@ -79,6 +81,8 @@ func NewHandler(bouncer security.BouncerService, demoService *demo.Service) *Han bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryAccess))).Methods(http.MethodPut) h.Handle("/endpoints/global-key", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointCreateGlobalKey))).Methods(http.MethodPost) + h.Handle("/endpoints/{id}/forceupdateservice", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointForceUpdateService))).Methods(http.MethodPut) // DEPRECATED h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet) diff --git a/api/http/server.go b/api/http/server.go index b14ecaf68..bcc1d4a1e 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -175,6 +175,7 @@ func (server *Server) Start() error { endpointHandler.ProxyManager = server.ProxyManager endpointHandler.SnapshotService = server.SnapshotService endpointHandler.K8sClientFactory = server.KubernetesClientFactory + endpointHandler.DockerClientFactory = server.DockerClientFactory endpointHandler.ReverseTunnelService = server.ReverseTunnelService endpointHandler.ComposeStackManager = server.ComposeStackManager endpointHandler.AuthorizationService = server.AuthorizationService diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html deleted file mode 100644 index e905618ca..000000000 --- a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.html +++ /dev/null @@ -1,29 +0,0 @@ -
-
- - -
- -
diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js deleted file mode 100644 index 8d4b918ac..000000000 --- a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActions.js +++ /dev/null @@ -1,11 +0,0 @@ -angular.module('portainer.docker').component('servicesDatatableActions', { - templateUrl: './servicesDatatableActions.html', - controller: 'ServicesDatatableActionsController', - bindings: { - selectedItems: '=', - selectedItemCount: '=', - showUpdateAction: '<', - showAddAction: '<', - endpointId: '<', - }, -}); diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js deleted file mode 100644 index c7da7b6e3..000000000 --- a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js +++ /dev/null @@ -1,88 +0,0 @@ -import { confirmDelete } from '@@/modals/confirm'; -import { confirmServiceForceUpdate } from '@/react/docker/services/common/update-service-modal'; -import { convertServiceToConfig } from '@/react/docker/services/common/convertServiceToConfig'; - -angular.module('portainer.docker').controller('ServicesDatatableActionsController', [ - '$q', - '$state', - 'ServiceService', - 'Notifications', - 'ImageHelper', - 'WebhookService', - function ($q, $state, ServiceService, Notifications, ImageHelper, WebhookService) { - const ctrl = this; - - this.removeAction = function (selectedItems) { - confirmDelete('Do you want to remove the selected service(s)? All the containers associated to the selected service(s) will be removed too.').then((confirmed) => { - if (!confirmed) { - return; - } - removeServices(selectedItems); - }); - }; - - this.updateAction = function (selectedItems) { - confirmServiceForceUpdate('Do you want to force an update of the selected service(s)? All the tasks associated to the selected service(s) will be recreated.').then( - (result) => { - if (!result) { - return; - } - - forceUpdateServices(selectedItems, result.pullLatest); - } - ); - }; - - function forceUpdateServices(services, pullImage) { - var actionCount = services.length; - angular.forEach(services, function (service) { - var config = convertServiceToConfig(service.Model); - if (pullImage) { - config.TaskTemplate.ContainerSpec.Image = ImageHelper.removeDigestFromRepository(config.TaskTemplate.ContainerSpec.Image); - } - - // As explained in https://github.com/docker/swarmkit/issues/2364 ForceUpdate can accept a random - // value or an increment of the counter value to force an update. - config.TaskTemplate.ForceUpdate++; - ServiceService.update(service, config) - .then(function success() { - Notifications.success('Service successfully updated', service.Name); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to force update service' + service.Name); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - } - - function removeServices(services) { - var actionCount = services.length; - angular.forEach(services, function (service) { - ServiceService.remove(service) - .then(function success() { - return WebhookService.webhooks(service.Id, ctrl.endpointId); - }) - .then(function success(data) { - return $q.when(data.length !== 0 && WebhookService.deleteWebhook(data[0].Id)); - }) - .then(function success() { - Notifications.success('Service successfully removed', service.Name); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove service'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - } - }, -]); diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html deleted file mode 100644 index fb014777c..000000000 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ /dev/null @@ -1,245 +0,0 @@ -
- - -
-
-
- -
- {{ $ctrl.titleText }} -
- - -
- - - - - - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - {{ item.StackName ? item.StackName : '-' }}{{ item.Image | hideshasum }} - {{ item.Mode }} - {{ item.Tasks | runningtaskscount }} / {{ item.Mode === 'replicated' ? item.Replicas : ($ctrl.nodes | availablenodecount: item) }} - - - - - - - {{ p.PublishedPort }}:{{ p.TargetPort }} - - - - {{ item.UpdatedAt | getisodate }} - - - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} - -
- -
Loading...
No service available.
-
- -
-
-
diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.js b/app/docker/components/datatables/services-datatable/servicesDatatable.js deleted file mode 100644 index d2449064e..000000000 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.js +++ /dev/null @@ -1,22 +0,0 @@ -angular.module('portainer.docker').component('servicesDatatable', { - templateUrl: './servicesDatatable.html', - controller: 'ServicesDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<', - nodes: '<', - agentProxy: '<', - showUpdateAction: '<', - showAddAction: '<', - showStackColumn: '<', - showTaskLogsButton: '<', - refreshCallback: '<', - notAutoFocus: '<', - endpointPublicUrl: '<', - endpointId: '<', - }, -}); diff --git a/app/docker/components/datatables/services-datatable/servicesDatatableController.js b/app/docker/components/datatables/services-datatable/servicesDatatableController.js deleted file mode 100644 index af47eb885..000000000 --- a/app/docker/components/datatables/services-datatable/servicesDatatableController.js +++ /dev/null @@ -1,143 +0,0 @@ -import _ from 'lodash-es'; - -angular.module('portainer.docker').controller('ServicesDatatableController', [ - '$scope', - '$controller', - 'DatatableService', - function ($scope, $controller, DatatableService) { - angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); - - var ctrl = this; - - this.state = Object.assign(this.state, { - expandAll: false, - expandedItems: [], - }); - - this.columnVisibility = { - columns: { - image: { - label: 'Image', - display: true, - }, - ownership: { - label: 'OwnerShip', - display: true, - }, - ports: { - label: 'Published Ports', - display: true, - }, - updated: { - label: 'Last Update', - display: true, - }, - }, - }; - - this.onColumnVisibilityChange = onColumnVisibilityChange.bind(this); - function onColumnVisibilityChange(columns) { - this.columnVisibility.columns = columns; - DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility); - } - - this.expandAll = function () { - this.state.expandAll = !this.state.expandAll; - for (var i = 0; i < this.state.filteredDataSet.length; i++) { - var item = this.state.filteredDataSet[i]; - this.expandItem(item, this.state.expandAll); - } - }; - - this.expandItem = function (item, expanded) { - item.Expanded = expanded; - if (item.Expanded) { - if (this.state.expandedItems.indexOf(item.Id) === -1) { - this.state.expandedItems.push(item.Id); - } - } else { - var index = this.state.expandedItems.indexOf(item.Id); - if (index > -1) { - this.state.expandedItems.splice(index, 1); - } - } - DatatableService.setDataTableExpandedItems(this.tableKey, this.state.expandedItems); - }; - - function expandPreviouslyExpandedItem(item, storedExpandedItems) { - var expandedItem = _.find(storedExpandedItems, function (storedId) { - return item.Id === storedId; - }); - - if (expandedItem) { - ctrl.expandItem(item, true); - } - } - - this.expandItems = function (storedExpandedItems) { - var expandedItemCount = 0; - this.state.expandedItems = storedExpandedItems; - - for (var i = 0; i < this.dataset.length; i++) { - var item = this.dataset[i]; - expandPreviouslyExpandedItem(item, storedExpandedItems); - if (item.Expanded) { - ++expandedItemCount; - } - } - - if (expandedItemCount === this.dataset.length) { - this.state.expandAll = true; - } - }; - - this.onDataRefresh = function () { - var storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey); - if (storedExpandedItems !== null) { - this.expandItems(storedExpandedItems); - } - }; - - this.$onInit = function () { - this.setDefaults(); - this.prepareTableFromDataset(); - - this.state.orderBy = this.orderBy; - var storedOrder = DatatableService.getDataTableOrder(this.tableKey); - if (storedOrder !== null) { - this.state.reverseOrder = storedOrder.reverse; - this.state.orderBy = storedOrder.orderBy; - } - - var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); - if (textFilter !== null) { - this.state.textFilter = textFilter; - this.onTextFilterChange(); - } - - var storedFilters = DatatableService.getDataTableFilters(this.tableKey); - if (storedFilters !== null) { - this.filters = storedFilters; - } - if (this.filters && this.filters.state) { - this.filters.state.open = false; - } - - var storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey); - if (storedExpandedItems !== null) { - this.expandItems(storedExpandedItems); - } - - var storedSettings = DatatableService.getDataTableSettings(this.tableKey); - if (storedSettings !== null) { - this.settings = storedSettings; - this.settings.open = false; - } - this.onSettingsRepeaterChange(); - var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey); - if (storedColumnVisibility !== null) { - this.columnVisibility = storedColumnVisibility; - } - }; - }, -]); diff --git a/app/docker/filters/filters.js b/app/docker/filters/filters.js index 9839394d9..e956dc9ff 100644 --- a/app/docker/filters/filters.js +++ b/app/docker/filters/filters.js @@ -1,5 +1,5 @@ import _ from 'lodash-es'; -import { joinCommand, taskStatusBadge, nodeStatusBadge, trimSHA, dockerNodeAvailabilityBadge } from './utils'; +import { hideShaSum, joinCommand, nodeStatusBadge, taskStatusBadge, trimSHA } from './utils'; function includeString(text, values) { return values.some(function (val) { @@ -76,7 +76,6 @@ angular }; }) .filter('nodestatusbadge', () => nodeStatusBadge) - .filter('dockerNodeAvailabilityBadge', () => dockerNodeAvailabilityBadge) .filter('trimcontainername', function () { 'use strict'; return function (name) { @@ -159,44 +158,7 @@ angular .filter('command', function () { return joinCommand; }) - .filter('hideshasum', function () { - 'use strict'; - return function (imageName) { - if (imageName) { - return imageName.split('@sha')[0]; - } - return ''; - }; - }) - .filter('availablenodecount', [ - 'ConstraintsHelper', - function (ConstraintsHelper) { - 'use strict'; - return function (nodes, service) { - var availableNodes = 0; - for (var i = 0; i < nodes.length; i++) { - var node = nodes[i]; - if (node.Availability === 'active' && node.Status === 'ready' && ConstraintsHelper.matchesServiceConstraints(service, node)) { - availableNodes++; - } - } - return availableNodes; - }; - }, - ]) - .filter('runningtaskscount', function () { - 'use strict'; - return function (tasks) { - var runningTasks = 0; - for (var i = 0; i < tasks.length; i++) { - var task = tasks[i]; - if (task.Status.State === 'running' && task.DesiredState === 'running') { - runningTasks++; - } - } - return runningTasks; - }; - }) + .filter('hideshasum', () => hideShaSum) .filter('tasknodename', function () { 'use strict'; return function (nodeId, nodes) { diff --git a/app/docker/filters/utils.ts b/app/docker/filters/utils.ts index 05723b239..8c3e633de 100644 --- a/app/docker/filters/utils.ts +++ b/app/docker/filters/utils.ts @@ -1,4 +1,4 @@ -import { NodeStatus, TaskState, NodeSpec } from 'docker-types/generated/1.41'; +import { NodeStatus, TaskState } from 'docker-types/generated/1.41'; import _ from 'lodash'; export function trimSHA(imageName: string) { @@ -62,14 +62,6 @@ export function nodeStatusBadge(text: NodeStatus['State']) { return 'success'; } -export function dockerNodeAvailabilityBadge(text: NodeSpec['Availability']) { - if (text === 'pause') { - return 'warning'; - } - - if (text === 'drain') { - return 'danger'; - } - - return 'success'; +export function hideShaSum(imageName = '') { + return imageName.split('@sha')[0]; } diff --git a/app/docker/helpers/constraintsHelper.js b/app/docker/helpers/constraintsHelper.js deleted file mode 100644 index c2288f71b..000000000 --- a/app/docker/helpers/constraintsHelper.js +++ /dev/null @@ -1,108 +0,0 @@ -import _ from 'lodash-es'; - -function ConstraintModel(op, key, value) { - this.op = op; - this.value = value; - this.key = key; -} - -var patterns = { - id: { - nodeId: 'node.id', - nodeHostname: 'node.hostname', - nodeRole: 'node.role', - nodeLabels: 'node.labels.', - engineLabels: 'engine.labels.', - }, - op: { - eq: '==', - neq: '!=', - }, -}; - -function matchesConstraint(value, constraint) { - if (!constraint || (constraint.op === patterns.op.eq && value === constraint.value) || (constraint.op === patterns.op.neq && value !== constraint.value)) { - return true; - } - return false; -} - -function matchesLabel(labels, constraint) { - if (!constraint) { - return true; - } - var found = _.find(labels, function (label) { - return label.key === constraint.key && label.value === constraint.value; - }); - return found !== undefined; -} - -function extractValue(constraint, op) { - return constraint.split(op).pop().trim(); -} - -function extractCustomLabelKey(constraint, op, baseLabelKey) { - return constraint.split(op).shift().trim().replace(baseLabelKey, ''); -} - -angular.module('portainer.docker').factory('ConstraintsHelper', [ - function ConstraintsHelperFactory() { - 'use strict'; - return { - transformConstraints: function (constraints) { - var transform = {}; - for (var i = 0; i < constraints.length; i++) { - var constraint = constraints[i]; - - var op; - if (constraint.includes(patterns.op.eq)) { - op = patterns.op.eq; - } else if (constraint.includes(patterns.op.neq)) { - op = patterns.op.neq; - } - - var value = extractValue(constraint, op); - var key = ''; - switch (true) { - case constraint.includes(patterns.id.nodeId): - transform.nodeId = new ConstraintModel(op, key, value); - break; - case constraint.includes(patterns.id.nodeHostname): - transform.nodeHostname = new ConstraintModel(op, key, value); - break; - case constraint.includes(patterns.id.nodeRole): - transform.nodeRole = new ConstraintModel(op, key, value); - break; - case constraint.includes(patterns.id.nodeLabels): - key = extractCustomLabelKey(constraint, op, patterns.id.nodeLabels); - transform.nodeLabels = new ConstraintModel(op, key, value); - break; - case constraint.includes(patterns.id.engineLabels): - key = extractCustomLabelKey(constraint, op, patterns.id.engineLabels); - transform.engineLabels = new ConstraintModel(op, key, value); - break; - default: - break; - } - } - return transform; - }, - matchesServiceConstraints: function (service, node) { - if (service.Constraints === undefined || service.Constraints.length === 0) { - return true; - } - var constraints = this.transformConstraints(angular.copy(service.Constraints)); - if ( - matchesConstraint(node.Id, constraints.nodeId) && - matchesConstraint(node.Hostname, constraints.nodeHostname) && - matchesConstraint(node.Role, constraints.nodeRole) && - matchesLabel(node.Labels, constraints.nodeLabels) && - matchesLabel(node.EngineLabels, constraints.engineLabels) - ) { - return true; - } - return false; - }, - }; - }, -]); diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts index 759b4e635..cfcdbbcb3 100644 --- a/app/docker/react/components/index.ts +++ b/app/docker/react/components/index.ts @@ -20,7 +20,6 @@ import { ConfigsDatatable } from '@/react/docker/configs/ListView/ConfigsDatatab import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser'; import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolumeBrowser'; import { ProcessesDatatable } from '@/react/docker/containers/StatsView/ProcessesDatatable'; -import { ScaleServiceButton } from '@/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleServiceButton'; import { SecretsDatatable } from '@/react/docker/secrets/ListView/SecretsDatatable'; import { StacksDatatable } from '@/react/docker/stacks/ListView/StacksDatatable'; @@ -124,10 +123,6 @@ const ngModule = angular 'dockerContainerProcessesDatatable', r2a(ProcessesDatatable, ['dataset', 'headers']) ) - .component( - 'dockerServicesDatatableScaleServiceButton', - r2a(withUIRouter(withCurrentUser(ScaleServiceButton)), ['service']) - ) .component('dockerEventsDatatable', r2a(EventsDatatable, ['dataset'])) .component( 'dockerSecretsDatatable', diff --git a/app/docker/react/components/services.ts b/app/docker/react/components/services.ts index ec4634823..046095038 100644 --- a/app/docker/react/components/services.ts +++ b/app/docker/react/components/services.ts @@ -5,6 +5,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter'; import { TasksDatatable } from '@/react/docker/services/ListView/ServicesDatatable/TasksDatatable'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { TaskTableQuickActions } from '@/react/docker/services/common/TaskTableQuickActions'; +import { ServicesDatatable } from '@/react/docker/services/ListView/ServicesDatatable'; export const servicesModule = angular .module('portainer.docker.react.components.services', []) @@ -18,4 +19,14 @@ export const servicesModule = angular 'state', 'taskId', ]) + ) + .component( + 'dockerServicesDatatable', + r2a(withUIRouter(withCurrentUser(ServicesDatatable)), [ + 'dataset', + 'isAddActionVisible', + 'isStackColumnVisible', + 'onRefresh', + 'titleIcon', + ]) ).name; diff --git a/app/docker/views/services/services.html b/app/docker/views/services/services.html index 0c2403f0d..fc1dcb9fd 100644 --- a/app/docker/views/services/services.html +++ b/app/docker/views/services/services.html @@ -1,22 +1,9 @@ -
-
- -
-
+ diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index d278e43d4..a69b07abd 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -239,27 +239,7 @@ environment="endpoint" > -
-
- -
-
+ )} - initialTableState={{ - columnVisibility: Object.fromEntries( - tableState.hiddenColumns.map((col) => [col, false]) - ), - }} - renderTableSettings={(tableInstance) => { - const columnsToHide = tableInstance - .getAllColumns() - .filter((col) => col.getCanHide()); - - return ( - <> - - columns={columnsToHide} - onChange={(hiddenColumns) => { - tableState.setHiddenColumns(hiddenColumns); - tableInstance.setColumnVisibility( - Object.fromEntries( - hiddenColumns.map((col) => [col, false]) - ) - ); - }} - value={tableState.hiddenColumns} - /> - } - > - - - - ); - }} + initialTableState={getColumnVisibilityState(tableState.hiddenColumns)} + renderTableSettings={(tableInstance) => ( + <> + + table={tableInstance} + onChange={(hiddenColumns) => { + tableState.setHiddenColumns(hiddenColumns); + }} + value={tableState.hiddenColumns} + /> + } + > + + + + )} dataset={containersQuery.data || []} isLoading={containersQuery.isLoading} emptyContentLabel="No containers found" diff --git a/app/react/components/buttons/ButtonGroup.tsx b/app/react/components/buttons/ButtonGroup.tsx index 9b459b0e3..eb197f745 100644 --- a/app/react/components/buttons/ButtonGroup.tsx +++ b/app/react/components/buttons/ButtonGroup.tsx @@ -9,7 +9,7 @@ export interface Props { } export function ButtonGroup({ - size = 'small', + size, children, className, 'aria-label': ariaLabel, diff --git a/app/react/components/datatables/ColumnVisibilityMenu.tsx b/app/react/components/datatables/ColumnVisibilityMenu.tsx index ca362cdfa..cb4daaa5c 100644 --- a/app/react/components/datatables/ColumnVisibilityMenu.tsx +++ b/app/react/components/datatables/ColumnVisibilityMenu.tsx @@ -2,21 +2,26 @@ import _ from 'lodash'; import clsx from 'clsx'; import { Menu, MenuButton, MenuList } from '@reach/menu-button'; import { Columns } from 'lucide-react'; -import { Column } from '@tanstack/react-table'; +import { Table } from '@tanstack/react-table'; import { Checkbox } from '@@/form-components/Checkbox'; interface Props { - columns: Column[]; onChange: (value: string[]) => void; value: string[]; + table: Table; } export function ColumnVisibilityMenu({ - columns, onChange, value, + table, }: Props) { + const columnsToHide = table.getAllColumns().filter((col) => col.getCanHide()); + if (!columnsToHide.length) { + return null; + } + return ( {({ isExpanded }) => ( @@ -38,7 +43,7 @@ export function ColumnVisibilityMenu({
Show / Hide Columns
- {columns.map((column) => ( + {columnsToHide.map((column) => (
({ ); function handleChangeColumnVisibility(colId: string, visible: boolean) { - if (visible) { - onChange(value.filter((id) => id !== colId)); - return; - } + const newValue = visible + ? value.filter((id) => id !== colId) + : [...value, colId]; - onChange([...value, colId]); + table.setColumnVisibility( + Object.fromEntries(newValue.map((col) => [col, false])) + ); + onChange(newValue); } } + +export function getColumnVisibilityState(hiddenColumns: string[]) { + return { + columnVisibility: Object.fromEntries( + hiddenColumns.map((col) => [col, false]) + ), + }; +} diff --git a/app/react/components/datatables/ExpandableDatatableRow.tsx b/app/react/components/datatables/ExpandableDatatableRow.tsx index 5a9e85353..319396716 100644 --- a/app/react/components/datatables/ExpandableDatatableRow.tsx +++ b/app/react/components/datatables/ExpandableDatatableRow.tsx @@ -23,7 +23,7 @@ export function ExpandableDatatableTableRow({ cells={cells} onClick={expandOnClick ? () => row.toggleExpanded() : undefined} /> - {row.getIsExpanded() && renderSubRow(row)} + {row.getIsExpanded() && row.getCanExpand() && renderSubRow(row)} ); } diff --git a/app/react/docker/components/ImageStatus/ImageStatus.tsx b/app/react/docker/components/ImageStatus/ImageStatus.tsx index 5dda8e0c6..1c36c13bb 100644 --- a/app/react/docker/components/ImageStatus/ImageStatus.tsx +++ b/app/react/docker/components/ImageStatus/ImageStatus.tsx @@ -3,6 +3,7 @@ import { Loader } from 'lucide-react'; import { useEnvironment } from '@/react/portainer/environments/queries'; import { statusIcon } from '@/react/docker/components/ImageStatus/helpers'; import { EnvironmentId } from '@/react/portainer/environments/types'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { Icon } from '@@/Icon'; @@ -39,6 +40,10 @@ export function ImageStatus({ return null; } + if (!isBE) { + return null; + } + if (isLoading || !data) { return ( !row.original.IsPortainer} - initialTableState={{ - columnVisibility: Object.fromEntries( - tableState.hiddenColumns.map((col) => [col, false]) - ), - }} - renderTableSettings={(tableInstance) => { - const columnsToHide = tableInstance - .getAllColumns() - .filter((col) => col.getCanHide()); - - return ( - <> - - columns={columnsToHide} - onChange={(hiddenColumns) => { - tableState.setHiddenColumns(hiddenColumns); - tableInstance.setColumnVisibility( - Object.fromEntries( - hiddenColumns.map((col) => [col, false]) - ) - ); - }} - value={tableState.hiddenColumns} + initialTableState={getColumnVisibilityState(tableState.hiddenColumns)} + renderTableSettings={(tableInstance) => ( + <> + + table={tableInstance} + onChange={(hiddenColumns) => { + tableState.setHiddenColumns(hiddenColumns); + }} + value={tableState.hiddenColumns} + /> + } + > + - } - > - - - - ); - }} + + + )} dataset={containersQuery.data || []} emptyContentLabel="No containers found" /> diff --git a/app/react/docker/services/ListView/ServicesDatatable/ServicesDatatable.tsx b/app/react/docker/services/ListView/ServicesDatatable/ServicesDatatable.tsx new file mode 100644 index 000000000..843b70292 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/ServicesDatatable.tsx @@ -0,0 +1,123 @@ +import { Shuffle } from 'lucide-react'; +import { Row } from '@tanstack/react-table'; + +import { ServiceViewModel } from '@/docker/models/service'; +import { useApiVersion } from '@/react/docker/proxy/queries/useVersion'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { IconProps } from '@@/Icon'; +import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; +import { + createPersistedStore, + refreshableSettings, + hiddenColumnsSettings, +} from '@@/datatables/types'; +import { useTableState } from '@@/datatables/useTableState'; +import { useRepeater } from '@@/datatables/useRepeater'; +import { defaultGlobalFilterFn } from '@@/datatables/Datatable'; +import { getColumnVisibilityState } from '@@/datatables/ColumnVisibilityMenu'; +import { mergeOptions } from '@@/datatables/extend-options/mergeOptions'; +import { withGlobalFilter } from '@@/datatables/extend-options/withGlobalFilter'; + +import { useColumns } from './columns'; +import { TasksDatatable } from './TasksDatatable'; +import { TableActions } from './TableActions'; +import { type TableSettings as TableSettingsType } from './types'; +import { TableSettings } from './TableSettings'; + +const tableKey = 'services'; + +const store = createPersistedStore( + tableKey, + 'name', + (set) => ({ + ...refreshableSettings(set), + ...hiddenColumnsSettings(set), + expanded: {}, + setExpanded(value) { + set({ expanded: value }); + }, + }) +); + +export function ServicesDatatable({ + titleIcon = Shuffle, + dataset, + isAddActionVisible, + isStackColumnVisible, + onRefresh, +}: { + dataset: Array | undefined; + titleIcon?: IconProps['icon']; + isAddActionVisible?: boolean; + isStackColumnVisible?: boolean; + onRefresh?(): void; +}) { + const environmentId = useEnvironmentId(); + const apiVersion = useApiVersion(environmentId); + const tableState = useTableState(store, tableKey); + const columns = useColumns(isStackColumnVisible); + useRepeater(tableState.autoRefreshRate, onRefresh); + + return ( + item.Tasks.length > 0} + renderSubRow={({ original: item }) => ( + + + + + + + )} + initialTableState={getColumnVisibilityState(tableState.hiddenColumns)} + renderTableActions={(selectedRows) => ( + = 1.25} + /> + )} + renderTableSettings={(table) => ( + + )} + extendTableOptions={mergeOptions( + (options) => ({ + ...options, + onExpandedChange: (updater) => { + const value = + typeof updater === 'function' + ? updater(tableState.expanded) + : updater; + tableState.setExpanded(value); + }, + state: { + expanded: tableState.expanded, + }, + }), + withGlobalFilter(filter) + )} + /> + ); +} + +function filter( + row: Row, + columnId: string, + filterValue: null | { search: string } +) { + return ( + defaultGlobalFilterFn(row, columnId, filterValue) || + row.original.Tasks.some((task) => + Object.values(task).some( + (value) => value && value.toString().includes(filterValue?.search || '') + ) + ) + ); +} diff --git a/app/react/docker/services/ListView/ServicesDatatable/TableActions.tsx b/app/react/docker/services/ListView/ServicesDatatable/TableActions.tsx new file mode 100644 index 000000000..8593ead6f --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/TableActions.tsx @@ -0,0 +1,118 @@ +import { Trash2, Plus, RefreshCw } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; + +import { ServiceViewModel } from '@/docker/models/service'; +import { Authorized } from '@/react/hooks/useUser'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { notifySuccess } from '@/portainer/services/notifications'; + +import { Link } from '@@/Link'; +import { Button, ButtonGroup } from '@@/buttons'; +import { confirmDelete } from '@@/modals/confirm'; + +import { confirmServiceForceUpdate } from '../../common/update-service-modal'; + +import { useRemoveServicesMutation } from './useRemoveServicesMutation'; +import { useForceUpdateServicesMutation } from './useForceUpdateServicesMutation'; + +export function TableActions({ + selectedItems, + isAddActionVisible, + isUpdateActionVisible, +}: { + selectedItems: Array; + isAddActionVisible?: boolean; + isUpdateActionVisible?: boolean; +}) { + const environmentId = useEnvironmentId(); + const removeMutation = useRemoveServicesMutation(environmentId); + const updateMutation = useForceUpdateServicesMutation(environmentId); + const router = useRouter(); + + return ( +
+ + {isUpdateActionVisible && ( + + + + )} + + + + + + {isAddActionVisible && ( + + + + )} +
+ ); + + async function handleUpdate(selectedItems: Array) { + const confirmed = await confirmServiceForceUpdate( + 'Do you want to force an update of the selected service(s)? All the tasks associated to the selected service(s) will be recreated.' + ); + + if (!confirmed) { + return; + } + + updateMutation.mutate( + { + ids: selectedItems.map((service) => service.Id), + pullImage: confirmed.pullLatest, + }, + { + onSuccess() { + notifySuccess('Success', 'Service(s) successfully updated'); + router.stateService.reload(); + }, + } + ); + } + + async function handleRemove(selectedItems: Array) { + const confirmed = await confirmDelete( + 'Do you want to remove the selected service(s)? All the containers associated to the selected service(s) will be removed too.' + ); + + if (!confirmed) { + return; + } + + removeMutation.mutate( + selectedItems.map((service) => service.Id), + { + onSuccess() { + notifySuccess('Success', 'Service(s) successfully removed'); + router.stateService.reload(); + }, + } + ); + } +} diff --git a/app/react/docker/services/ListView/ServicesDatatable/TableSettings.tsx b/app/react/docker/services/ListView/ServicesDatatable/TableSettings.tsx new file mode 100644 index 000000000..4145c10e7 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/TableSettings.tsx @@ -0,0 +1,35 @@ +import { Table } from '@tanstack/react-table'; + +import { ServiceViewModel } from '@/docker/models/service'; + +import { TableSettingsMenu } from '@@/datatables'; +import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; +import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu'; + +import { type TableSettings as TableSettingsType } from './types'; + +export function TableSettings({ + settings, + table, +}: { + settings: TableSettingsType; + table: Table; +}) { + return ( + <> + + table={table} + onChange={(hiddenColumns) => { + settings.setHiddenColumns(hiddenColumns); + }} + value={settings.hiddenColumns} + /> + + settings.setAutoRefreshRate(value)} + /> + + + ); +} diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/helper.ts b/app/react/docker/services/ListView/ServicesDatatable/columns/helper.ts new file mode 100644 index 000000000..ddabe6644 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { ServiceViewModel } from '@/docker/models/service'; + +export const columnHelper = createColumnHelper(); diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/image.tsx b/app/react/docker/services/ListView/ServicesDatatable/columns/image.tsx new file mode 100644 index 000000000..ff71a6669 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/image.tsx @@ -0,0 +1,43 @@ +import { CellContext } from '@tanstack/react-table'; + +import { ServiceViewModel } from '@/docker/models/service'; +import { ImageStatus } from '@/react/docker/components/ImageStatus'; +import { hideShaSum } from '@/docker/filters/utils'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { ResourceType } from '@/react/docker/components/ImageStatus/types'; +import { ImageUpToDateTooltip } from '@/react/docker/components/datatable/TableColumnHeaderImageUpToDate'; + +import { columnHelper } from './helper'; + +export const image = columnHelper.accessor((item) => item.Image, { + id: 'image', + header: Header, + cell: Cell, +}); + +function Header() { + return ( + <> + Image + + + ); +} + +function Cell({ + getValue, + row: { original: item }, +}: CellContext) { + const value = hideShaSum(getValue()); + const environmentId = useEnvironmentId(); + return ( + <> + + {value} + + ); +} diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/index.ts b/app/react/docker/services/ListView/ServicesDatatable/columns/index.ts new file mode 100644 index 000000000..599d71026 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/index.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; +import _ from 'lodash'; + +import { ServiceViewModel } from '@/docker/models/service'; +import { isoDate } from '@/portainer/filters/filters'; +import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn'; + +import { buildNameColumn } from '@@/datatables/buildNameColumn'; +import { buildExpandColumn } from '@@/datatables/expand-column'; + +import { image } from './image'; +import { columnHelper } from './helper'; +import { schedulingMode } from './schedulingMode'; +import { ports } from './ports'; + +export function useColumns(isStackColumnVisible?: boolean) { + return useMemo( + () => + _.compact([ + buildExpandColumn(), + buildNameColumn('Name', 'docker.services.service'), + isStackColumnVisible && + columnHelper.accessor((item) => item.StackName || '-', { + header: 'Stack', + enableHiding: false, + }), + image, + schedulingMode, + ports, + columnHelper.accessor('UpdatedAt', { + header: 'Last Update', + cell: ({ getValue }) => isoDate(getValue()), + }), + createOwnershipColumn(), + ]), + [isStackColumnVisible] + ); +} diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/ports.tsx b/app/react/docker/services/ListView/ServicesDatatable/columns/ports.tsx new file mode 100644 index 000000000..926d942b3 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/ports.tsx @@ -0,0 +1,55 @@ +import { ExternalLink } from 'lucide-react'; +import { CellContext } from '@tanstack/react-table'; + +import { ServiceViewModel } from '@/docker/models/service'; +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; + +import { Icon } from '@@/Icon'; + +import { columnHelper } from './helper'; + +export const ports = columnHelper.accessor( + (row) => + (row.Ports || []) + .filter((port) => port.PublishedPort) + .map((port) => `${port.PublishedPort}:${port.TargetPort}`) + .join(','), + { + header: 'Published Ports', + id: 'ports', + cell: Cell, + } +); + +function Cell({ + row: { original: item }, +}: CellContext) { + const environmentQuery = useCurrentEnvironment(); + + if (!environmentQuery.data) { + return null; + } + + const ports = item.Ports || []; + + if (ports.length === 0) { + return '-'; + } + + const { PublicURL: publicUrl } = environmentQuery.data; + + return ports + .filter((port) => port.PublishedPort) + .map((port) => ( + + + {port.PublishedPort}:{port.TargetPort} + + )); +} diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/constraint-helper.ts b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/constraint-helper.ts new file mode 100644 index 000000000..889917986 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/constraint-helper.ts @@ -0,0 +1,133 @@ +import { Node } from 'docker-types/generated/1.41'; + +import { ServiceViewModel } from '@/docker/models/service'; + +class ConstraintModel { + op: string; + + value: string; + + key: string; + + constructor(op: string, key: string, value: string) { + this.op = op; + this.value = value; + this.key = key; + } +} + +const patterns = { + id: { + nodeId: 'node.id', + nodeHostname: 'node.hostname', + nodeRole: 'node.role', + nodeLabels: 'node.labels.', + engineLabels: 'engine.labels.', + }, + op: { + eq: '==', + neq: '!=', + }, +} as const; + +function matchesConstraint( + value: string | undefined, + constraint?: ConstraintModel +) { + if ( + !constraint || + (constraint.op === patterns.op.eq && value === constraint.value) || + (constraint.op === patterns.op.neq && value !== constraint.value) + ) { + return true; + } + return false; +} + +function matchesLabel( + labels: Record | undefined, + constraint?: ConstraintModel +) { + if (!constraint) { + return true; + } + return Object.entries(labels || {}).some( + ([key, value]) => key === constraint.key && value === constraint.value + ); +} + +function extractValue(constraint: string, op: string) { + return constraint.split(op).pop()?.trim() || ''; +} + +function extractCustomLabelKey( + constraint: string, + op: string, + baseLabelKey: string +) { + return constraint.split(op).shift()?.trim().replace(baseLabelKey, '') || ''; +} + +interface Constraint { + nodeId?: ConstraintModel; + nodeHostname?: ConstraintModel; + nodeRole?: ConstraintModel; + nodeLabels?: ConstraintModel; + engineLabels?: ConstraintModel; +} + +function transformConstraints(constraints: Array) { + const transform: Constraint = {}; + for (let i = 0; i < constraints.length; i++) { + const constraint = constraints[i]; + + let op = ''; + if (constraint.includes(patterns.op.eq)) { + op = patterns.op.eq; + } else if (constraint.includes(patterns.op.neq)) { + op = patterns.op.neq; + } + + const value = extractValue(constraint, op); + let key = ''; + switch (true) { + case constraint.includes(patterns.id.nodeId): + transform.nodeId = new ConstraintModel(op, key, value); + break; + case constraint.includes(patterns.id.nodeHostname): + transform.nodeHostname = new ConstraintModel(op, key, value); + break; + case constraint.includes(patterns.id.nodeRole): + transform.nodeRole = new ConstraintModel(op, key, value); + break; + case constraint.includes(patterns.id.nodeLabels): + key = extractCustomLabelKey(constraint, op, patterns.id.nodeLabels); + transform.nodeLabels = new ConstraintModel(op, key, value); + break; + case constraint.includes(patterns.id.engineLabels): + key = extractCustomLabelKey(constraint, op, patterns.id.engineLabels); + transform.engineLabels = new ConstraintModel(op, key, value); + break; + default: + break; + } + } + return transform; +} + +export function matchesServiceConstraints( + service: ServiceViewModel, + node: Node +) { + if (service.Constraints === undefined || service.Constraints.length === 0) { + return true; + } + const constraints = transformConstraints([...service.Constraints]); + return ( + matchesConstraint(node.ID, constraints.nodeId) && + matchesConstraint(node.Description?.Hostname, constraints.nodeHostname) && + matchesConstraint(node.Spec?.Role, constraints.nodeRole) && + matchesLabel(node.Spec?.Labels, constraints.nodeLabels) && + matchesLabel(node.Description?.Engine?.Labels, constraints.engineLabels) + ); +} diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/index.ts b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/index.ts new file mode 100644 index 000000000..7159bff65 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/index.ts @@ -0,0 +1 @@ +export { schedulingMode } from './schedulingMode'; diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/schedulingMode.tsx b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/schedulingMode.tsx new file mode 100644 index 000000000..53b3f2956 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/schedulingMode.tsx @@ -0,0 +1,66 @@ +import { CellContext } from '@tanstack/react-table'; +import { Node } from 'docker-types/generated/1.41'; + +import { ServiceViewModel } from '@/docker/models/service'; +import { useNodes } from '@/react/docker/proxy/queries/nodes/useNodes'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { TaskViewModel } from '@/docker/models/task'; + +import { columnHelper } from '../helper'; + +import { matchesServiceConstraints } from './constraint-helper'; +import { ScaleServiceButton } from './ScaleServiceButton'; + +export const schedulingMode = columnHelper.accessor('Mode', { + header: 'Scheduling Mode', + cell: Cell, + enableHiding: false, +}); + +function Cell({ + getValue, + row: { original: item }, +}: CellContext) { + const environmentId = useEnvironmentId(); + const nodesQuery = useNodes(environmentId); + + if (!nodesQuery.data) { + return null; + } + + const mode = getValue(); + return ( +
+ {mode} + {totalRunningTasks(item.Tasks)} /{' '} + + {mode === 'replicated' + ? item.Replicas + : availableNodeCount(nodesQuery.data, item)} + + {mode === 'replicated' && } +
+ ); +} + +function totalRunningTasks(tasks: Array) { + return tasks.filter( + (task) => + task.Status?.State === 'running' && task.DesiredState === 'running' + ).length; +} + +function availableNodeCount(nodes: Array, service: ServiceViewModel) { + let availableNodes = 0; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ( + node.Spec?.Availability === 'active' && + node.Status?.State === 'ready' && + matchesServiceConstraints(service, node) + ) { + availableNodes++; + } + } + return availableNodes; +} diff --git a/app/react/docker/services/ListView/ServicesDatatable/index.ts b/app/react/docker/services/ListView/ServicesDatatable/index.ts new file mode 100644 index 000000000..2c44e471e --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/index.ts @@ -0,0 +1 @@ +export { ServicesDatatable } from './ServicesDatatable'; diff --git a/app/react/docker/services/ListView/ServicesDatatable/types.ts b/app/react/docker/services/ListView/ServicesDatatable/types.ts new file mode 100644 index 000000000..2b50727ff --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/types.ts @@ -0,0 +1,13 @@ +import { + BasicTableSettings, + RefreshableTableSettings, + SettableColumnsTableSettings, +} from '@@/datatables/types'; + +export type TableSettings = { + /** expanded is true (all expanded) or a record where each key value pair sets the state of the mentioned row */ + expanded: true | Record; + setExpanded(value: true | Record): void; +} & SettableColumnsTableSettings & + RefreshableTableSettings & + BasicTableSettings; diff --git a/app/react/docker/services/ListView/ServicesDatatable/useForceUpdateServicesMutation.ts b/app/react/docker/services/ListView/ServicesDatatable/useForceUpdateServicesMutation.ts new file mode 100644 index 000000000..73718e418 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/useForceUpdateServicesMutation.ts @@ -0,0 +1,16 @@ +import { useMutation } from 'react-query'; + +import { promiseSequence } from '@/portainer/helpers/promise-utils'; +import { withError } from '@/react-tools/react-query'; +import { forceUpdateService } from '@/react/portainer/environments/environment.service'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export function useForceUpdateServicesMutation(environmentId: EnvironmentId) { + return useMutation( + ({ ids, pullImage }: { ids: Array; pullImage: boolean }) => + promiseSequence( + ids.map((id) => () => forceUpdateService(environmentId, id, pullImage)) + ), + withError('Failed to remove services') + ); +} diff --git a/app/react/docker/services/ListView/ServicesDatatable/useRemoveServicesMutation.ts b/app/react/docker/services/ListView/ServicesDatatable/useRemoveServicesMutation.ts new file mode 100644 index 000000000..c2a6edb98 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/useRemoveServicesMutation.ts @@ -0,0 +1,27 @@ +import { useMutation } from 'react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { promiseSequence } from '@/portainer/helpers/promise-utils'; +import { withError } from '@/react-tools/react-query'; + +import { urlBuilder } from '../../axios/urlBuilder'; +import { removeWebhooksForService } from '../../webhooks/removeWebhook'; + +export function useRemoveServicesMutation(environmentId: EnvironmentId) { + return useMutation( + (ids: Array) => + promiseSequence(ids.map((id) => () => removeService(environmentId, id))), + withError('Unable to remove services') + ); +} + +async function removeService(environmentId: EnvironmentId, serviceId: string) { + try { + await axios.delete(urlBuilder(environmentId, serviceId)); + + await removeWebhooksForService(environmentId, serviceId); + } catch (error) { + throw parseAxiosError(error); + } +} diff --git a/app/react/docker/services/axios/urlBuilder.ts b/app/react/docker/services/axios/urlBuilder.ts index b1c83c52b..2d6b74594 100644 --- a/app/react/docker/services/axios/urlBuilder.ts +++ b/app/react/docker/services/axios/urlBuilder.ts @@ -1,10 +1,8 @@ -import { Service } from 'docker-types/generated/1.41'; - import { EnvironmentId } from '@/react/portainer/environments/types'; export function urlBuilder( endpointId: EnvironmentId, - id?: Service['ID'], + id?: string, action?: string ) { let url = `/endpoints/${endpointId}/docker/services`; diff --git a/app/react/docker/services/webhooks/build-url.ts b/app/react/docker/services/webhooks/build-url.ts new file mode 100644 index 000000000..7e75434e5 --- /dev/null +++ b/app/react/docker/services/webhooks/build-url.ts @@ -0,0 +1,4 @@ +export function buildUrl(webhookId?: string) { + const baseUrl = '/webhooks'; + return webhookId ? `${baseUrl}/${webhookId}` : baseUrl; +} diff --git a/app/react/docker/services/webhooks/getWebhooks.ts b/app/react/docker/services/webhooks/getWebhooks.ts new file mode 100644 index 000000000..358fccdff --- /dev/null +++ b/app/react/docker/services/webhooks/getWebhooks.ts @@ -0,0 +1,19 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildUrl } from './build-url'; +import { Webhook } from './types'; + +export async function getWebhooks( + environmentId: EnvironmentId, + serviceId: string +) { + try { + const { data } = await axios.get>(buildUrl(), { + params: { filters: { EndpointID: environmentId, ResourceID: serviceId } }, + }); + return data; + } catch (error) { + throw parseAxiosError(error); + } +} diff --git a/app/react/docker/services/webhooks/removeWebhook.ts b/app/react/docker/services/webhooks/removeWebhook.ts new file mode 100644 index 000000000..550c521d7 --- /dev/null +++ b/app/react/docker/services/webhooks/removeWebhook.ts @@ -0,0 +1,25 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { promiseSequence } from '@/portainer/helpers/promise-utils'; + +import { getWebhooks } from './getWebhooks'; +import { Webhook } from './types'; +import { buildUrl } from './build-url'; + +export async function removeWebhooksForService( + environmentId: EnvironmentId, + serviceId: string +) { + const webhooks = await getWebhooks(environmentId, serviceId); + return promiseSequence( + webhooks.map((webhook) => () => removeWebhook(webhook.Id)) + ); +} + +export async function removeWebhook(webhookId: Webhook['Id']) { + try { + await axios.delete(buildUrl(webhookId)); + } catch (err) { + throw parseAxiosError(err); + } +} diff --git a/app/react/docker/services/webhooks/types.ts b/app/react/docker/services/webhooks/types.ts new file mode 100644 index 000000000..5eef4e071 --- /dev/null +++ b/app/react/docker/services/webhooks/types.ts @@ -0,0 +1,16 @@ +import { Environment } from '@/react/portainer/environments/types'; +import { Registry } from '@/react/portainer/registries/types'; + +enum WebhookType { + Service = 1, + Container = 2, +} + +export interface Webhook { + Id: string; + Token: string; + ResourceId: string; + EndpointId: Environment['Id']; + RegistryId: Registry['Id']; + Type: WebhookType; +} diff --git a/app/react/docker/stacks/ListView/StacksDatatable/TableSettingsMenus.tsx b/app/react/docker/stacks/ListView/StacksDatatable/TableSettingsMenus.tsx index 079ce540c..61b708684 100644 --- a/app/react/docker/stacks/ListView/StacksDatatable/TableSettingsMenus.tsx +++ b/app/react/docker/stacks/ListView/StacksDatatable/TableSettingsMenus.tsx @@ -17,19 +17,12 @@ export function TableSettingsMenus({ tableInstance: Table; tableState: TableSettings; }) { - const columnsToHide = tableInstance - .getAllColumns() - .filter((col) => col.getCanHide()); - return ( <> - columns={columnsToHide} + table={tableInstance} onChange={(hiddenColumns) => { tableState.setHiddenColumns(hiddenColumns); - tableInstance.setColumnVisibility( - Object.fromEntries(hiddenColumns.map((col) => [col, false])) - ); }} value={tableState.hiddenColumns} /> diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx index e1f334ba7..69512e201 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx @@ -2,6 +2,7 @@ import { Layers } from 'lucide-react'; import { Datatable } from '@@/datatables'; import { useTableState } from '@@/datatables/useTableState'; +import { getColumnVisibilityState } from '@@/datatables/ColumnVisibilityMenu'; import { useEdgeStacks } from '../../queries/useEdgeStacks'; import { EdgeStack, StatusType } from '../../types'; @@ -33,6 +34,7 @@ export function EdgeStacksDatatable() { titleIcon={Layers} columns={columns} dataset={edgeStacksQuery.data || []} + initialTableState={getColumnVisibilityState(tableState.hiddenColumns)} settingsManager={tableState} emptyContentLabel="No stack available." isLoading={edgeStacksQuery.isLoading} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableSettingsMenus.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableSettingsMenus.tsx index 7062c66e6..3baa13980 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableSettingsMenus.tsx +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableSettingsMenus.tsx @@ -14,24 +14,17 @@ export function TableSettingsMenus({ tableInstance: Table; tableState: TableSettings; }) { - const columnsToHide = tableInstance - .getAllColumns() - .filter((col) => col.getCanHide()); - return ( <> - {columnsToHide && columnsToHide.length > 0 && ( - - columns={columnsToHide} - onChange={(hiddenColumns) => { - tableState.setHiddenColumns(hiddenColumns); - tableInstance.setColumnVisibility( - Object.fromEntries(hiddenColumns.map((col) => [col, false])) - ); - }} - value={tableState.hiddenColumns} - /> - )} + ( + + table={tableInstance} + onChange={(hiddenColumns) => { + tableState.setHiddenColumns(hiddenColumns); + }} + value={tableState.hiddenColumns} + /> + ) ( - +