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 }) => (
+
+ )}
+
+ );
+
+ function handleSubmit({ replicas }: { replicas: number }) {
+ const config = convertServiceToConfig(service.Model);
+ mutation.mutate(
+ {
+ serviceId: service.Id,
+ config: {
+ ...config,
+ Mode: {
+ ...config.Mode,
+ Replicated: {
+ ...config.Mode?.Replicated,
+ Replicas: replicas,
+ },
+ },
+ },
+ environmentId,
+ version: service.Version || 0,
+ },
+ {
+ onSuccess() {
+ onClose();
+ notifySuccess(
+ 'Service successfully scaled',
+ `New replica count: ${replicas}`
+ );
+ router.stateService.reload();
+ },
+ }
+ );
+ }
+}
diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleServiceButton.tsx b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleServiceButton.tsx
new file mode 100644
index 000000000..2f72f0b06
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleServiceButton.tsx
@@ -0,0 +1,25 @@
+import { Minimize2 } from 'lucide-react';
+import { useState } from 'react';
+
+import { ServiceViewModel } from '@/docker/models/service';
+import { Authorized } from '@/react/hooks/useUser';
+
+import { Button } from '@@/buttons';
+
+import { ScaleForm } from './ScaleForm';
+
+export function ScaleServiceButton({ service }: { service: ServiceViewModel }) {
+ const [isEdit, setIsEdit] = useState(false);
+
+ if (!isEdit) {
+ return (
+
+
+
+ );
+ }
+
+ 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"
|