diff --git a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js index 91ba1fb5a..c7da7b6e3 100644 --- a/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js +++ b/app/docker/components/datatables/services-datatable/actions/servicesDatatableActionsController.js @@ -1,32 +1,17 @@ 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', - 'ServiceHelper', 'Notifications', 'ImageHelper', 'WebhookService', - function ($q, $state, ServiceService, ServiceHelper, Notifications, ImageHelper, WebhookService) { + function ($q, $state, ServiceService, Notifications, ImageHelper, WebhookService) { const ctrl = this; - this.scaleAction = function scaleService(service) { - var config = ServiceHelper.serviceToConfig(service.Model); - config.Mode.Replicated.Replicas = service.Replicas; - ServiceService.update(service, config) - .then(function success() { - Notifications.success('Service successfully scaled', 'New replica count: ' + service.Replicas); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to scale service'); - service.Scale = false; - service.Replicas = service.ReplicaCount; - }); - }; - 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) { @@ -51,7 +36,7 @@ angular.module('portainer.docker').controller('ServicesDatatableActionsControlle function forceUpdateServices(services, pullImage) { var actionCount = services.length; angular.forEach(services, function (service) { - var config = ServiceHelper.serviceToConfig(service.Model); + var config = convertServiceToConfig(service.Model); if (pullImage) { config.TaskTemplate.ContainerSpec.Image = ImageHelper.removeDigestFromRepository(config.TaskTemplate.ContainerSpec.Image); } diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index 173c57ced..7a9ff7969 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -178,32 +178,11 @@ {{ item.StackName ? item.StackName : '-' }} {{ item.Image | hideshasum }} - + {{ item.Mode }} {{ item.Tasks | runningtaskscount }} / {{ item.Mode === 'replicated' ? item.Replicas : ($ctrl.nodes | availablenodecount: item) }} - - - - Scale - - - - - - - - - - + + diff --git a/app/docker/helpers/serviceHelper.js b/app/docker/helpers/serviceHelper.js index cd2e658ab..3f5ce28e5 100644 --- a/app/docker/helpers/serviceHelper.js +++ b/app/docker/helpers/serviceHelper.js @@ -21,18 +21,6 @@ angular.module('portainer.docker').factory('ServiceHelper', [ tasks = otherServicesTasks; }; - helper.serviceToConfig = function (service) { - return { - Name: service.Spec.Name, - Labels: service.Spec.Labels, - TaskTemplate: service.Spec.TaskTemplate, - Mode: service.Spec.Mode, - UpdateConfig: service.Spec.UpdateConfig, - Networks: service.Spec.Networks, - EndpointSpec: service.Spec.EndpointSpec, - }; - }; - helper.translateKeyValueToPlacementPreferences = function (keyValuePreferences) { if (keyValuePreferences) { var preferences = []; diff --git a/app/docker/models/service.js b/app/docker/models/service.js deleted file mode 100644 index de8bdc9c0..000000000 --- a/app/docker/models/service.js +++ /dev/null @@ -1,117 +0,0 @@ -import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; - -export function ServiceViewModel(data, runningTasks, allTasks) { - this.Model = data; - this.Id = data.ID; - this.Tasks = []; - this.Name = data.Spec.Name; - this.CreatedAt = data.CreatedAt; - this.UpdatedAt = data.UpdatedAt; - this.Image = data.Spec.TaskTemplate.ContainerSpec.Image; - this.Version = data.Version.Index; - if (data.Spec.Mode.Replicated) { - this.Mode = 'replicated'; - this.Replicas = data.Spec.Mode.Replicated.Replicas; - } else { - this.Mode = 'global'; - if (allTasks) { - this.Replicas = allTasks.length; - } - } - if (runningTasks) { - this.Running = runningTasks.length; - } - if (data.Spec.TaskTemplate.Resources) { - if (data.Spec.TaskTemplate.Resources.Limits) { - this.LimitNanoCPUs = data.Spec.TaskTemplate.Resources.Limits.NanoCPUs; - this.LimitMemoryBytes = data.Spec.TaskTemplate.Resources.Limits.MemoryBytes; - } - if (data.Spec.TaskTemplate.Resources.Reservations) { - this.ReservationNanoCPUs = data.Spec.TaskTemplate.Resources.Reservations.NanoCPUs; - this.ReservationMemoryBytes = data.Spec.TaskTemplate.Resources.Reservations.MemoryBytes; - } - } - - if (data.Spec.TaskTemplate.RestartPolicy) { - this.RestartCondition = data.Spec.TaskTemplate.RestartPolicy.Condition || 'any'; - this.RestartDelay = data.Spec.TaskTemplate.RestartPolicy.Delay || 5000000000; - this.RestartMaxAttempts = data.Spec.TaskTemplate.RestartPolicy.MaxAttempts || 0; - this.RestartWindow = data.Spec.TaskTemplate.RestartPolicy.Window || 0; - } else { - this.RestartCondition = 'any'; - this.RestartDelay = 5000000000; - this.RestartMaxAttempts = 0; - this.RestartWindow = 0; - } - - if (data.Spec.TaskTemplate.LogDriver) { - this.LogDriverName = data.Spec.TaskTemplate.LogDriver.Name || ''; - this.LogDriverOpts = data.Spec.TaskTemplate.LogDriver.Options || []; - } else { - this.LogDriverName = ''; - this.LogDriverOpts = []; - } - - this.Constraints = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Constraints || [] : []; - this.Preferences = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Preferences || [] : []; - this.Platforms = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Platforms || [] : []; - this.Labels = data.Spec.Labels; - if (this.Labels && this.Labels['com.docker.stack.namespace']) { - this.StackName = this.Labels['com.docker.stack.namespace']; - } - - var containerSpec = data.Spec.TaskTemplate.ContainerSpec; - if (containerSpec) { - this.ContainerLabels = containerSpec.Labels; - this.Command = containerSpec.Command; - this.Arguments = containerSpec.Args; - this.Hostname = containerSpec.Hostname; - this.Env = containerSpec.Env; - this.Dir = containerSpec.Dir; - this.User = containerSpec.User; - this.Groups = containerSpec.Groups; - this.TTY = containerSpec.TTY; - this.OpenStdin = containerSpec.OpenStdin; - this.ReadOnly = containerSpec.ReadOnly; - this.Mounts = containerSpec.Mounts || []; - this.StopSignal = containerSpec.StopSignal; - this.StopGracePeriod = containerSpec.StopGracePeriod; - this.HealthCheck = containerSpec.HealthCheck || {}; - this.Hosts = containerSpec.Hosts; - this.DNSConfig = containerSpec.DNSConfig; - this.Secrets = containerSpec.Secrets; - this.Configs = containerSpec.Configs; - } - if (data.Endpoint) { - this.Ports = data.Endpoint.Ports; - } - - this.LogDriver = data.Spec.TaskTemplate.LogDriver; - this.Runtime = data.Spec.TaskTemplate.Runtime; - - this.VirtualIPs = data.Endpoint ? data.Endpoint.VirtualIPs : []; - - if (data.Spec.UpdateConfig) { - this.UpdateParallelism = typeof data.Spec.UpdateConfig.Parallelism !== 'undefined' ? data.Spec.UpdateConfig.Parallelism || 0 : 1; - this.UpdateDelay = data.Spec.UpdateConfig.Delay || 0; - this.UpdateFailureAction = data.Spec.UpdateConfig.FailureAction || 'pause'; - this.UpdateOrder = data.Spec.UpdateConfig.Order || 'stop-first'; - } else { - this.UpdateParallelism = 1; - this.UpdateDelay = 0; - this.UpdateFailureAction = 'pause'; - this.UpdateOrder = 'stop-first'; - } - - this.RollbackConfig = data.Spec.RollbackConfig; - - this.Checked = false; - this.Scale = false; - this.EditName = false; - - if (data.Portainer) { - if (data.Portainer.ResourceControl) { - this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); - } - } -} diff --git a/app/docker/models/service.ts b/app/docker/models/service.ts new file mode 100644 index 000000000..23fbda1fa --- /dev/null +++ b/app/docker/models/service.ts @@ -0,0 +1,269 @@ +import { + EndpointPortConfig, + HealthConfig, + Mount, + Platform, + Service, + ServiceSpec, + TaskSpec, +} from 'docker-types/generated/1.41'; + +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; +import { PortainerMetadata } from '@/react/docker/types'; +import { WithRequiredProperty } from '@/types'; + +import { TaskViewModel } from './task'; + +type ContainerSpec = WithRequiredProperty< + TaskSpec, + 'ContainerSpec' +>['ContainerSpec']; + +export class ServiceViewModel { + Model: Service; + + Id: string; + + Tasks: TaskViewModel[]; + + Name: string; + + CreatedAt: string | undefined; + + UpdatedAt: string | undefined; + + Image: string | undefined; + + Version: number | undefined; + + Mode: string; + + Replicas: number | undefined; + + Running?: number; + + LimitNanoCPUs: number | undefined; + + LimitMemoryBytes: number | undefined; + + ReservationNanoCPUs: number | undefined; + + ReservationMemoryBytes: number | undefined; + + RestartCondition: string; + + RestartDelay: number; + + RestartMaxAttempts: number; + + RestartWindow: number; + + LogDriverName: string; + + LogDriverOpts: never[] | Record; + + Constraints: string[]; + + Preferences: { + Spread?: { SpreadDescriptor?: string | undefined } | undefined; + }[]; + + Platforms?: Array; + + Labels: Record | undefined; + + StackName?: string; + + ContainerLabels: Record | undefined; + + Command: string[] | undefined; + + Arguments: string[] | undefined; + + Hostname: string | undefined; + + Env: string[] | undefined; + + Dir: string | undefined; + + User: string | undefined; + + Groups: string[] | undefined; + + TTY: boolean | undefined; + + OpenStdin: boolean | undefined; + + ReadOnly: boolean | undefined; + + Mounts?: Array; + + StopSignal: string | undefined; + + StopGracePeriod: number | undefined; + + HealthCheck?: HealthConfig; + + Hosts: string[] | undefined; + + DNSConfig?: ContainerSpec['DNSConfig']; + + Secrets?: ContainerSpec['Secrets']; + + Configs: ContainerSpec['Configs']; + + Ports?: Array; + + LogDriver: TaskSpec['LogDriver']; + + Runtime: string | undefined; + + VirtualIPs: + | { NetworkID?: string | undefined; Addr?: string | undefined }[] + | undefined; + + UpdateParallelism: number; + + UpdateDelay: number; + + UpdateFailureAction: string; + + UpdateOrder: string; + + RollbackConfig: ServiceSpec['RollbackConfig']; + + Checked: boolean; + + Scale: boolean; + + EditName: boolean; + + ResourceControl?: ResourceControlViewModel; + + constructor(data: Service & { Portainer?: PortainerMetadata }) { + this.Model = data; + this.Id = data.ID || ''; + this.Tasks = []; + this.Name = data.Spec?.Name || ''; + this.CreatedAt = data.CreatedAt; + this.UpdatedAt = data.UpdatedAt; + this.Image = data.Spec?.TaskTemplate?.ContainerSpec?.Image; + this.Version = data.Version?.Index; + if (data.Spec?.Mode?.Replicated) { + this.Mode = 'replicated'; + this.Replicas = data.Spec.Mode.Replicated.Replicas; + } else { + this.Mode = 'global'; + } + + if (data.Spec?.TaskTemplate?.Resources) { + if (data.Spec.TaskTemplate.Resources.Limits) { + this.LimitNanoCPUs = data.Spec.TaskTemplate.Resources.Limits.NanoCPUs; + this.LimitMemoryBytes = + data.Spec.TaskTemplate.Resources.Limits.MemoryBytes; + } + if (data.Spec.TaskTemplate.Resources.Reservations) { + this.ReservationNanoCPUs = + data.Spec.TaskTemplate.Resources.Reservations.NanoCPUs; + this.ReservationMemoryBytes = + data.Spec.TaskTemplate.Resources.Reservations.MemoryBytes; + } + } + + if (data.Spec?.TaskTemplate?.RestartPolicy) { + this.RestartCondition = + data.Spec.TaskTemplate.RestartPolicy.Condition || 'any'; + this.RestartDelay = + data.Spec.TaskTemplate.RestartPolicy.Delay || 5000000000; + this.RestartMaxAttempts = + data.Spec.TaskTemplate.RestartPolicy.MaxAttempts || 0; + this.RestartWindow = data.Spec.TaskTemplate.RestartPolicy.Window || 0; + } else { + this.RestartCondition = 'any'; + this.RestartDelay = 5000000000; + this.RestartMaxAttempts = 0; + this.RestartWindow = 0; + } + + if (data.Spec?.TaskTemplate?.LogDriver) { + this.LogDriverName = data.Spec.TaskTemplate.LogDriver.Name || ''; + this.LogDriverOpts = data.Spec.TaskTemplate.LogDriver.Options || []; + } else { + this.LogDriverName = ''; + this.LogDriverOpts = []; + } + + this.Constraints = data.Spec?.TaskTemplate?.Placement + ? data.Spec.TaskTemplate.Placement.Constraints || [] + : []; + this.Preferences = data.Spec?.TaskTemplate?.Placement + ? data.Spec.TaskTemplate.Placement.Preferences || [] + : []; + this.Platforms = data.Spec?.TaskTemplate?.Placement?.Platforms || []; + this.Labels = data.Spec?.Labels; + if (this.Labels && this.Labels['com.docker.stack.namespace']) { + this.StackName = this.Labels['com.docker.stack.namespace']; + } + + const containerSpec = data.Spec?.TaskTemplate?.ContainerSpec; + if (containerSpec) { + this.ContainerLabels = containerSpec.Labels; + this.Command = containerSpec.Command; + this.Arguments = containerSpec.Args; + this.Hostname = containerSpec.Hostname; + this.Env = containerSpec.Env; + this.Dir = containerSpec.Dir; + this.User = containerSpec.User; + this.Groups = containerSpec.Groups; + this.TTY = containerSpec.TTY; + this.OpenStdin = containerSpec.OpenStdin; + this.ReadOnly = containerSpec.ReadOnly; + this.Mounts = containerSpec.Mounts || []; + this.StopSignal = containerSpec.StopSignal; + this.StopGracePeriod = containerSpec.StopGracePeriod; + this.HealthCheck = containerSpec.HealthCheck || {}; + this.Hosts = containerSpec.Hosts; + this.DNSConfig = containerSpec.DNSConfig; + this.Secrets = containerSpec.Secrets; + this.Configs = containerSpec.Configs; + } + if (data.Endpoint) { + this.Ports = data.Endpoint.Ports; + } + + this.LogDriver = data.Spec?.TaskTemplate?.LogDriver; + this.Runtime = data.Spec?.TaskTemplate?.Runtime; + + this.VirtualIPs = data.Endpoint ? data.Endpoint.VirtualIPs : []; + + if (data.Spec?.UpdateConfig) { + this.UpdateParallelism = + typeof data.Spec.UpdateConfig.Parallelism !== 'undefined' + ? data.Spec.UpdateConfig.Parallelism || 0 + : 1; + this.UpdateDelay = data.Spec.UpdateConfig.Delay || 0; + this.UpdateFailureAction = + data.Spec.UpdateConfig.FailureAction || 'pause'; + this.UpdateOrder = data.Spec.UpdateConfig.Order || 'stop-first'; + } else { + this.UpdateParallelism = 1; + this.UpdateDelay = 0; + this.UpdateFailureAction = 'pause'; + this.UpdateOrder = 'stop-first'; + } + + this.RollbackConfig = data.Spec?.RollbackConfig; + + this.Checked = false; + this.Scale = false; + this.EditName = false; + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel( + data.Portainer.ResourceControl + ); + } + } + } +} diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts index edf9adc49..d35300f99 100644 --- a/app/docker/react/components/index.ts +++ b/app/docker/react/components/index.ts @@ -21,6 +21,7 @@ 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 { containersModule } from './containers'; @@ -123,10 +124,14 @@ const ngModule = angular 'relativePath', ]) ) - .component('dockerEventsDatatable', r2a(EventsDatatable, ['dataset'])) .component( 'dockerContainerProcessesDatatable', r2a(ProcessesDatatable, ['dataset', 'headers']) - ); + ) + .component( + 'dockerServicesDatatableScaleServiceButton', + r2a(withUIRouter(withCurrentUser(ScaleServiceButton)), ['service']) + ) + .component('dockerEventsDatatable', r2a(EventsDatatable, ['dataset'])); export const componentsModule = ngModule.name; diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js index 004f82112..7b6f9bfc5 100644 --- a/app/docker/views/services/edit/serviceController.js +++ b/app/docker/views/services/edit/serviceController.js @@ -26,6 +26,7 @@ import { confirmServiceForceUpdate } from '@/react/docker/services/common/update import { confirm, confirmDelete } from '@@/modals/confirm'; import { ModalType } from '@@/modals'; import { buildConfirmButton } from '@@/modals/utils'; +import { convertServiceToConfig } from '@/react/docker/services/common/convertServiceToConfig'; angular.module('portainer.docker').controller('ServiceController', [ '$q', @@ -438,7 +439,7 @@ angular.module('portainer.docker').controller('ServiceController', [ } function buildChanges(service) { - var config = ServiceHelper.serviceToConfig(service.Model); + var config = convertServiceToConfig(service.Model); config.Name = service.Name; config.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceLabels); config.TaskTemplate.ContainerSpec.Env = envVarsUtils.convertToArrayOfStrings(service.EnvironmentVariables); diff --git a/app/react-tools/react-query.ts b/app/react-tools/react-query.ts index fe1136dee..441b7c495 100644 --- a/app/react-tools/react-query.ts +++ b/app/react-tools/react-query.ts @@ -29,7 +29,7 @@ type OptionalReadonly = T | Readonly; export function withInvalidate( queryClient: QueryClient, - queryKeysToInvalidate: OptionalReadonly[] + queryKeysToInvalidate: Array>> ) { return { onSuccess() { diff --git a/app/react/docker/containers/StatsView/ProcessesDatatable.tsx b/app/react/docker/containers/StatsView/ProcessesDatatable.tsx index 8f30f246e..63f479fde 100644 --- a/app/react/docker/containers/StatsView/ProcessesDatatable.tsx +++ b/app/react/docker/containers/StatsView/ProcessesDatatable.tsx @@ -35,9 +35,9 @@ export function ProcessesDatatable({ headers ? headers.map( (header) => - ({ header, accessorKey: header } satisfies ColumnDef<{ + ({ header, accessorKey: header }) satisfies ColumnDef<{ [k: string]: string; - }>) + }> ) : [], [headers] diff --git a/app/react/docker/proxy/queries/query-keys.ts b/app/react/docker/proxy/queries/query-keys.ts new file mode 100644 index 000000000..2439c835e --- /dev/null +++ b/app/react/docker/proxy/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export const queryKeys = { + base: (environmentId: EnvironmentId) => + [environmentId, 'docker', 'proxy'] as const, +}; diff --git a/app/react/docker/services/ListView/.keep b/app/react/docker/services/ListView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleForm.tsx b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleForm.tsx new file mode 100644 index 000000000..8cafed392 --- /dev/null +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleForm.tsx @@ -0,0 +1,91 @@ +import { Formik, Form } from 'formik'; +import { X, CheckSquare } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; + +import { ServiceViewModel } from '@/docker/models/service'; +import { useUpdateServiceMutation } from '@/react/docker/services/queries/useUpdateServiceMutation'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { convertServiceToConfig } from '@/react/docker/services/common/convertServiceToConfig'; +import { notifySuccess } from '@/portainer/services/notifications'; + +import { Button, LoadingButton } from '@@/buttons'; + +export function ScaleForm({ + onClose, + service, +}: { + onClose: () => void; + service: ServiceViewModel; +}) { + const environmentId = useEnvironmentId(); + const mutation = useUpdateServiceMutation(); + const router = useRouter(); + return ( + + {({ values, setFieldValue }) => ( +
+ { + if (event.key === 'Escape') { + onClose(); + } + }} + onChange={(event) => { + setFieldValue('replicas', event.target.valueAsNumber); + }} + // disabled because it makes sense to auto focus once the form is mounted + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus + /> +
+ + ); + } + + return setIsEdit(false)} service={service} />; +} diff --git a/app/react/docker/services/axios/urlBuilder.ts b/app/react/docker/services/axios/urlBuilder.ts new file mode 100644 index 000000000..b1c83c52b --- /dev/null +++ b/app/react/docker/services/axios/urlBuilder.ts @@ -0,0 +1,21 @@ +import { Service } from 'docker-types/generated/1.41'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export function urlBuilder( + endpointId: EnvironmentId, + id?: Service['ID'], + action?: string +) { + let url = `/endpoints/${endpointId}/docker/services`; + + if (id) { + url += `/${id}`; + } + + if (action) { + url += `/${action}`; + } + + return url; +} diff --git a/app/react/docker/services/common/convertServiceToConfig.ts b/app/react/docker/services/common/convertServiceToConfig.ts new file mode 100644 index 000000000..8b10059ef --- /dev/null +++ b/app/react/docker/services/common/convertServiceToConfig.ts @@ -0,0 +1,15 @@ +import { Service } from 'docker-types/generated/1.41'; + +import { ServiceUpdateConfig } from '../queries/useUpdateServiceMutation'; + +export function convertServiceToConfig(service: Service): ServiceUpdateConfig { + return { + Name: service.Spec?.Name || '', + Labels: service.Spec?.Labels || {}, + TaskTemplate: service.Spec?.TaskTemplate || {}, + Mode: service.Spec?.Mode || {}, + UpdateConfig: service.Spec?.UpdateConfig || {}, + Networks: service.Spec?.Networks || [], + EndpointSpec: service.Spec?.EndpointSpec || {}, + }; +} diff --git a/app/react/docker/services/queries/query-keys.ts b/app/react/docker/services/queries/query-keys.ts new file mode 100644 index 000000000..d36ba2599 --- /dev/null +++ b/app/react/docker/services/queries/query-keys.ts @@ -0,0 +1,11 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys as dockerQueryKeys } from '../../queries/utils'; + +export const queryKeys = { + list: (environmentId: EnvironmentId) => + [...dockerQueryKeys.root(environmentId), 'services'] as const, + + service: (environmentId: EnvironmentId, id: string) => + [...queryKeys.list(environmentId), id] as const, +}; diff --git a/app/react/docker/services/queries/useUpdateServiceMutation.ts b/app/react/docker/services/queries/useUpdateServiceMutation.ts new file mode 100644 index 000000000..da98e8b1f --- /dev/null +++ b/app/react/docker/services/queries/useUpdateServiceMutation.ts @@ -0,0 +1,80 @@ +import { + TaskSpec, + ServiceSpec, + ServiceUpdateResponse, +} from 'docker-types/generated/1.41'; +import { useMutation, useQueryClient } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { mutationOptions, withError } from '@/react-tools/react-query'; + +import { encodeRegistryCredentials } from '../../images/queries/encodeRegistryCredentials'; +import { urlBuilder } from '../axios/urlBuilder'; + +import { queryKeys } from './query-keys'; + +export function useUpdateServiceMutation() { + const queryClient = useQueryClient(); + + return useMutation( + updateService, + mutationOptions( + { + onSuccess(data, { environmentId }) { + return queryClient.invalidateQueries(queryKeys.list(environmentId)); + }, + }, + withError('Unable to update service') + ) + ); +} + +async function updateService({ + environmentId, + serviceId, + config, + rollback, + version, + registryId, +}: { + environmentId: EnvironmentId; + serviceId: string; + config: ServiceUpdateConfig; + rollback?: 'previous'; + version: number; + registryId?: number; +}) { + try { + const { data } = await axios.post( + urlBuilder(environmentId, serviceId, 'update'), + config, + { + params: { + rollback, + version, + }, + ...(registryId + ? { + headers: { + 'X-Registry-Id': encodeRegistryCredentials(registryId), + }, + } + : {}), + } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to update service'); + } +} + +export interface ServiceUpdateConfig { + Name: string; + Labels: Record; + TaskTemplate: TaskSpec; + Mode: ServiceSpec['Mode']; + UpdateConfig: ServiceSpec['UpdateConfig']; + Networks: ServiceSpec['Networks']; + EndpointSpec: ServiceSpec['EndpointSpec']; +} diff --git a/app/types.ts b/app/types.ts index d1f8658da..4521a2bfb 100644 --- a/app/types.ts +++ b/app/types.ts @@ -13,3 +13,7 @@ declare module 'react' { DOMAttributes, AutomationTestingProps {} } + +export type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property]; +}; diff --git a/yarn.lock b/yarn.lock index cf3224478..d995dc6ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15773,9 +15773,9 @@ unc-path-regex@^0.1.2: integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= undici@^5.4.0: - version "5.22.1" - resolved "https://registry.yarnpkg.com/undici/-/undici-5.22.1.tgz#877d512effef2ac8be65e695f3586922e1a57d7b" - integrity sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw== + version "5.23.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.23.0.tgz#e7bdb0ed42cebe7b7aca87ced53e6eaafb8f8ca0" + integrity sha512-1D7w+fvRsqlQ9GscLBwcAJinqcZGHUKjbOmXdlE/v8BvEGXjeWAax+341q44EuTcHXXnfyKNbKRq4Lg7OzhMmg== dependencies: busboy "^1.6.0"