diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go index baf22fc7e..b258e5471 100644 --- a/api/http/handler/webhooks/webhook_execute.go +++ b/api/http/handler/webhooks/webhook_execute.go @@ -122,6 +122,7 @@ func (handler *Handler) executeServiceWebhook( _ = rc.Close() }(rc) } + _, err = dockerClient.ServiceUpdate(context.Background(), resourceID, service.Version, service.Spec, serviceUpdateOptions) if err != nil { diff --git a/api/http/proxy/factory/docker/build.go b/api/http/proxy/factory/docker/build.go index ea71ce312..77c5e2863 100644 --- a/api/http/proxy/factory/docker/build.go +++ b/api/http/proxy/factory/docker/build.go @@ -20,11 +20,16 @@ type postDockerfileRequest struct { } // buildOperation inspects the "Content-Type" header to determine if it needs to alter the request. +// // If the value of the header is empty, it means that a Dockerfile is posted via upload, the function // will extract the file content from the request body, tar it, and rewrite the body. +// !! THIS IS ONLY TRUE WHEN THE UPLOADED DOCKERFILE FILE HAS NO EXTENSION (the generated file.type in the frontend will be empty) +// If the Dockerfile is named like Dockerfile.yaml or has an internal type, a non-empty Content-Type header will be generated +// // If the value of the header contains "application/json", it means that the content of a Dockerfile is posted // in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and // rewrite the body of the request. +// // In any other case, it will leave the request unaltered. func buildOperation(request *http.Request) error { contentTypeHeader := request.Header.Get("Content-Type") diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index 97e9ddb00..ad4875714 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -84,11 +84,28 @@ func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, er return transport.ProxyDockerRequest(request) } +var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*http.Response, error){ + "configs": (*Transport).proxyConfigRequest, + "containers": (*Transport).proxyContainerRequest, + "services": (*Transport).proxyServiceRequest, + "volumes": (*Transport).proxyVolumeRequest, + "networks": (*Transport).proxyNetworkRequest, + "secrets": (*Transport).proxySecretRequest, + "swarm": (*Transport).proxySwarmRequest, + "nodes": (*Transport).proxyNodeRequest, + "tasks": (*Transport).proxyTaskRequest, + "build": (*Transport).proxyBuildRequest, + "images": (*Transport).proxyImageRequest, + "v2": (*Transport).proxyAgentRequest, +} + // ProxyDockerRequest intercepts a Docker API request and apply logic based // on the requested operation. func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) { - requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") - request.URL.Path = requestPath + // from : /v1.41/containers/{id}/json + // or : /containers/{id}/json + // to : /containers/{id}/json + unversionedPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") if transport.endpoint.Type == portainer.AgentOnDockerEnvironment || transport.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) @@ -100,34 +117,16 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) } - switch { - case strings.HasPrefix(requestPath, "/configs"): - return transport.proxyConfigRequest(request) - case strings.HasPrefix(requestPath, "/containers"): - return transport.proxyContainerRequest(request) - case strings.HasPrefix(requestPath, "/services"): - return transport.proxyServiceRequest(request) - case strings.HasPrefix(requestPath, "/volumes"): - return transport.proxyVolumeRequest(request) - case strings.HasPrefix(requestPath, "/networks"): - return transport.proxyNetworkRequest(request) - case strings.HasPrefix(requestPath, "/secrets"): - return transport.proxySecretRequest(request) - case strings.HasPrefix(requestPath, "/swarm"): - return transport.proxySwarmRequest(request) - case strings.HasPrefix(requestPath, "/nodes"): - return transport.proxyNodeRequest(request) - case strings.HasPrefix(requestPath, "/tasks"): - return transport.proxyTaskRequest(request) - case strings.HasPrefix(requestPath, "/build"): - return transport.proxyBuildRequest(request) - case strings.HasPrefix(requestPath, "/images"): - return transport.proxyImageRequest(request) - case strings.HasPrefix(requestPath, "/v2"): - return transport.proxyAgentRequest(request) - default: - return transport.executeDockerRequest(request) + // from : /containers/{id}/json + // trim to : containers/{id}/json + // pick : [ containers, {id}, json ][0] + // prefix : containers + prefix := strings.Split(strings.TrimPrefix(unversionedPath, "/"), "/")[0] + + if proxyFunc := prefixProxyFuncMap[prefix]; proxyFunc != nil { + return proxyFunc(transport, request, unversionedPath) } + return transport.executeDockerRequest(request) } func (transport *Transport) executeDockerRequest(request *http.Request) (*http.Response, error) { @@ -144,8 +143,8 @@ func (transport *Transport) executeDockerRequest(request *http.Request) (*http.R return response, err } -func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, error) { - requestPath := strings.TrimPrefix(r.URL.Path, "/v2") +func (transport *Transport) proxyAgentRequest(r *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := strings.TrimPrefix(unversionedPath, "/v2") switch { case strings.HasPrefix(requestPath, "/browse"): @@ -203,8 +202,10 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response, return transport.executeDockerRequest(r) } -func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { +func (transport *Transport) proxyConfigRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { case "/configs/create": return transport.decorateGenericResourceCreationOperation(request, configObjectIdentifier, portainer.ConfigResourceControl) @@ -225,8 +226,10 @@ func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Res } } -func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { +func (transport *Transport) proxyContainerRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { case "/containers/create": return transport.decorateContainerCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl) @@ -261,8 +264,10 @@ func (transport *Transport) proxyContainerRequest(request *http.Request) (*http. } } -func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { +func (transport *Transport) proxyServiceRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { case "/services/create": return transport.decorateServiceCreationOperation(request) @@ -292,8 +297,10 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re } } -func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { +func (transport *Transport) proxyVolumeRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { case "/volumes/create": return transport.decorateVolumeResourceCreationOperation(request, portainer.VolumeResourceControl) @@ -309,8 +316,10 @@ func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Res } } -func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { +func (transport *Transport) proxyNetworkRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { case "/networks/create": return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl) @@ -330,8 +339,10 @@ func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Re } } -func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { +func (transport *Transport) proxySecretRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { case "/secrets/create": return transport.decorateGenericResourceCreationOperation(request, secretObjectIdentifier, portainer.SecretResourceControl) @@ -351,8 +362,8 @@ func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Res } } -func (transport *Transport) proxyNodeRequest(request *http.Request) (*http.Response, error) { - requestPath := request.URL.Path +func (transport *Transport) proxyNodeRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath // assume /nodes/{id} if path.Base(requestPath) != "nodes" { @@ -362,8 +373,10 @@ func (transport *Transport) proxyNodeRequest(request *http.Request) (*http.Respo return transport.executeDockerRequest(request) } -func (transport *Transport) proxySwarmRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { +func (transport *Transport) proxySwarmRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { case "/swarm": return transport.rewriteOperation(request, swarmInspectOperation) default: @@ -372,8 +385,10 @@ func (transport *Transport) proxySwarmRequest(request *http.Request) (*http.Resp } } -func (transport *Transport) proxyTaskRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { +func (transport *Transport) proxyTaskRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { case "/tasks": return transport.rewriteOperation(request, transport.taskListOperation) default: @@ -382,7 +397,7 @@ func (transport *Transport) proxyTaskRequest(request *http.Request) (*http.Respo } } -func (transport *Transport) proxyBuildRequest(request *http.Request) (*http.Response, error) { +func (transport *Transport) proxyBuildRequest(request *http.Request, _ string) (*http.Response, error) { err := transport.updateDefaultGitBranch(request) if err != nil { return nil, err @@ -408,8 +423,10 @@ func (transport *Transport) updateDefaultGitBranch(request *http.Request) error return nil } -func (transport *Transport) proxyImageRequest(request *http.Request) (*http.Response, error) { - switch requestPath := request.URL.Path; requestPath { +func (transport *Transport) proxyImageRequest(request *http.Request, unversionedPath string) (*http.Response, error) { + requestPath := unversionedPath + + switch requestPath { case "/images/create": return transport.replaceRegistryAuthenticationHeader(request) default: diff --git a/app/docker/helpers/imageHelper.js b/app/docker/helpers/imageHelper.js index 6d1a78e77..0bacd6c53 100644 --- a/app/docker/helpers/imageHelper.js +++ b/app/docker/helpers/imageHelper.js @@ -5,7 +5,6 @@ function ImageHelperFactory() { return { isValidTag, createImageConfigForContainer, - getImagesNamesForDownload, removeDigestFromRepository, imageContainsURL, }; @@ -14,20 +13,6 @@ function ImageHelperFactory() { return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g); } - /** - * - * @param {Array<{tags: Array; id: string;}>} images - * @returns {{names: string[]}}} - */ - function getImagesNamesForDownload(images) { - var names = images.map(function (image) { - return image.tags[0] !== ':' ? image.tags[0] : image.id; - }); - return { - names, - }; - } - /** * * @param {PorImageRegistryModel} registry diff --git a/app/docker/helpers/volumeHelper.js b/app/docker/helpers/volumeHelper.js index 58f735397..b856129a4 100644 --- a/app/docker/helpers/volumeHelper.js +++ b/app/docker/helpers/volumeHelper.js @@ -3,14 +3,6 @@ angular.module('portainer.docker').factory('VolumeHelper', [ 'use strict'; var helper = {}; - helper.createDriverOptions = function (optionArray) { - var options = {}; - optionArray.forEach(function (option) { - options[option.name] = option.value; - }); - return options; - }; - helper.isVolumeUsedByAService = function (volume, services) { for (var i = 0; i < services.length; i++) { var service = services[i]; diff --git a/app/docker/models/build.ts b/app/docker/models/build.ts new file mode 100644 index 000000000..ac480f5c5 --- /dev/null +++ b/app/docker/models/build.ts @@ -0,0 +1,33 @@ +type Data = { + stream: string; + errorDetail: { message: string }; +}; + +export class ImageBuildModel { + hasError: boolean = false; + + buildLogs: string[]; + + constructor(data: Data[]) { + const buildLogs: string[] = []; + + data.forEach((line) => { + if (line.stream) { + // convert unicode chars to readable chars + const logLine = line.stream.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '' + ); + buildLogs.push(logLine); + } + + if (line.errorDetail) { + buildLogs.push(line.errorDetail.message); + this.hasError = true; + } + }); + + this.buildLogs = buildLogs; + } +} diff --git a/app/docker/models/config.js b/app/docker/models/config.js deleted file mode 100644 index 1980d7007..000000000 --- a/app/docker/models/config.js +++ /dev/null @@ -1,30 +0,0 @@ -import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; - -function b64DecodeUnicode(str) { - try { - return decodeURIComponent( - atob(str) - .split('') - .map(function (c) { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); - }) - .join('') - ); - } catch (err) { - return atob(str); - } -} - -export function ConfigViewModel(data) { - this.Id = data.ID; - this.CreatedAt = data.CreatedAt; - this.UpdatedAt = data.UpdatedAt; - this.Version = data.Version.Index; - this.Name = data.Spec.Name; - this.Labels = data.Spec.Labels; - this.Data = b64DecodeUnicode(data.Spec.Data); - - if (data.Portainer && data.Portainer.ResourceControl) { - this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); - } -} diff --git a/app/docker/models/config.ts b/app/docker/models/config.ts new file mode 100644 index 000000000..b3068c625 --- /dev/null +++ b/app/docker/models/config.ts @@ -0,0 +1,54 @@ +import { Config } from 'docker-types/generated/1.41'; + +import { IResource } from '@/react/docker/components/datatables/createOwnershipColumn'; +import { PortainerResponse } from '@/react/docker/types'; +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; + +export class ConfigViewModel implements IResource { + Id: string; + + CreatedAt: string; + + UpdatedAt: string; + + Version: number; + + Name: string; + + Labels: Record; + + Data: string; + + ResourceControl?: ResourceControlViewModel; + + constructor(data: PortainerResponse) { + this.Id = data.ID || ''; + this.CreatedAt = data.CreatedAt || ''; + this.UpdatedAt = data.UpdatedAt || ''; + this.Version = data.Version?.Index || 0; + this.Name = data.Spec?.Name || ''; + this.Labels = data.Spec?.Labels || {}; + this.Data = b64DecodeUnicode(data.Spec?.Data || ''); + + if (data.Portainer && data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel( + data.Portainer.ResourceControl + ); + } + } +} + +function b64DecodeUnicode(str: string) { + try { + return decodeURIComponent( + window + .atob(str) + .toString() + .split('') + .map((c) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`) + .join('') + ); + } catch (err) { + return window.atob(str); + } +} diff --git a/app/docker/models/container.js b/app/docker/models/container.js deleted file mode 100644 index 4d9a378a1..000000000 --- a/app/docker/models/container.js +++ /dev/null @@ -1,145 +0,0 @@ -import _ from 'lodash-es'; -import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; - -export function createStatus(statusText) { - var status = _.toLower(statusText); - - if (status.indexOf('paused') > -1) { - return 'paused'; - } else if (status.indexOf('dead') > -1) { - return 'dead'; - } else if (status.indexOf('created') > -1) { - return 'created'; - } else if (status.indexOf('exited') > -1) { - return 'stopped'; - } else if (status.indexOf('(healthy)') > -1) { - return 'healthy'; - } else if (status.indexOf('(unhealthy)') > -1) { - return 'unhealthy'; - } else if (status.indexOf('(health: starting)') > -1) { - return 'starting'; - } - return 'running'; -} - -export function ContainerViewModel(data) { - this.Id = data.Id; - this.Status = createStatus(data.Status); - this.State = data.State; - this.Created = data.Created; - this.Names = data.Names; - // Unavailable in Docker < 1.10 - if (data.NetworkSettings && !_.isEmpty(data.NetworkSettings.Networks)) { - this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress; - } - this.NetworkSettings = data.NetworkSettings; - this.Image = data.Image; - this.ImageID = data.ImageID; - this.Command = data.Command; - this.Checked = false; - this.Labels = data.Labels; - if (this.Labels && this.Labels['com.docker.compose.project']) { - this.StackName = this.Labels['com.docker.compose.project']; - } else if (this.Labels && this.Labels['com.docker.stack.namespace']) { - this.StackName = this.Labels['com.docker.stack.namespace']; - } - this.Mounts = data.Mounts; - - this.IsPortainer = data.IsPortainer; - - this.Ports = []; - if (data.Ports) { - for (var i = 0; i < data.Ports.length; ++i) { - var p = data.Ports[i]; - if (p.PublicPort) { - this.Ports.push({ host: p.IP, private: p.PrivatePort, public: p.PublicPort }); - } - } - } - - if (data.Portainer) { - if (data.Portainer.ResourceControl) { - this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); - } - if (data.Portainer.Agent && data.Portainer.Agent.NodeName) { - this.NodeName = data.Portainer.Agent.NodeName; - } - } -} - -export function ContainerStatsViewModel(data) { - this.read = data.read; - this.preread = data.preread; - if (data.memory_stats.privateworkingset !== undefined) { - // Windows - this.MemoryUsage = data.memory_stats.privateworkingset; - this.MemoryCache = 0; - this.NumProcs = data.num_procs; - this.isWindows = true; - } else { - // Linux - if (data.memory_stats.stats === undefined || data.memory_stats.usage === undefined) { - this.MemoryUsage = this.MemoryCache = 0; - } else { - this.MemoryCache = 0; - if (data.memory_stats.stats.cache !== undefined) { - // cgroups v1 - this.MemoryCache = data.memory_stats.stats.cache; - } - this.MemoryUsage = data.memory_stats.usage - this.MemoryCache; - } - } - this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage; - this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage; - this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage; - this.CurrentCPUSystemUsage = data.cpu_stats.system_cpu_usage; - this.CPUCores = 1; - if (data.cpu_stats.cpu_usage.percpu_usage) { - this.CPUCores = data.cpu_stats.cpu_usage.percpu_usage.length; - } else { - if (data.cpu_stats.online_cpus !== undefined) { - this.CPUCores = data.cpu_stats.online_cpus; - } - } - this.Networks = _.values(data.networks); - if (data.blkio_stats !== undefined && data.blkio_stats.io_service_bytes_recursive !== null) { - //TODO: take care of multiple block devices - var readData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'Read'); - if (readData === undefined) { - // try the cgroups v2 version - readData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'read'); - } - if (readData !== undefined) { - this.BytesRead = readData.value; - } - var writeData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'Write'); - if (writeData === undefined) { - // try the cgroups v2 version - writeData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'write'); - } - if (writeData !== undefined) { - this.BytesWrite = writeData.value; - } - } else { - //no IO related data is available - this.noIOdata = true; - } -} - -export function ContainerDetailsViewModel(data) { - this.Model = data; - this.Id = data.Id; - this.State = data.State; - this.Created = data.Created; - this.Name = data.Name; - this.NetworkSettings = data.NetworkSettings; - this.Args = data.Args; - this.Image = data.Image; - this.Config = data.Config; - this.HostConfig = data.HostConfig; - this.Mounts = data.Mounts; - if (data.Portainer && data.Portainer.ResourceControl) { - this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); - } - this.IsPortainer = data.IsPortainer; -} diff --git a/app/docker/models/containerDetails.ts b/app/docker/models/containerDetails.ts new file mode 100644 index 000000000..4676da6d6 --- /dev/null +++ b/app/docker/models/containerDetails.ts @@ -0,0 +1,56 @@ +import { IResource } from '@/react/docker/components/datatables/createOwnershipColumn'; +import { ContainerDetailsResponse } from '@/react/docker/containers/queries/useContainer'; +import { PortainerResponse } from '@/react/docker/types'; +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; + +export class ContainerDetailsViewModel + implements IResource, Pick, 'IsPortainer'> +{ + Model: ContainerDetailsResponse; + + Id: ContainerDetailsResponse['Id']; + + State: ContainerDetailsResponse['State']; + + Created: ContainerDetailsResponse['Created']; + + Name: ContainerDetailsResponse['Name']; + + NetworkSettings: ContainerDetailsResponse['NetworkSettings']; + + Args: ContainerDetailsResponse['Args']; + + Image: ContainerDetailsResponse['Image']; + + Config: ContainerDetailsResponse['Config']; + + HostConfig: ContainerDetailsResponse['HostConfig']; + + Mounts: ContainerDetailsResponse['Mounts']; + + // IResource + ResourceControl?: ResourceControlViewModel; + + // PortainerResponse + IsPortainer?: ContainerDetailsResponse['IsPortainer']; + + constructor(data: ContainerDetailsResponse) { + this.Model = data; + this.Id = data.Id; + this.State = data.State; + this.Created = data.Created; + this.Name = data.Name; + this.NetworkSettings = data.NetworkSettings; + this.Args = data.Args; + this.Image = data.Image; + this.Config = data.Config; + this.HostConfig = data.HostConfig; + this.Mounts = data.Mounts; + if (data.Portainer && data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel( + data.Portainer.ResourceControl + ); + } + this.IsPortainer = data.IsPortainer; + } +} diff --git a/app/docker/models/containerStats.ts b/app/docker/models/containerStats.ts new file mode 100644 index 000000000..547a3f311 --- /dev/null +++ b/app/docker/models/containerStats.ts @@ -0,0 +1,113 @@ +import { values } from 'lodash'; + +import { ContainerStats } from '@/react/docker/containers/queries/useContainerStats'; +import { ValueOf } from '@/types'; + +/** + * This type is arbitrary and only defined based on what we use / observed from the API responses. + */ +export class ContainerStatsViewModel { + read: string; + + preread: string; + + MemoryUsage: number; + + MemoryCache: number = 0; + + NumProcs: number = 0; + + isWindows: boolean = false; + + PreviousCPUTotalUsage: number; + + PreviousCPUSystemUsage: number; + + CurrentCPUTotalUsage: number; + + CurrentCPUSystemUsage: number; + + CPUCores: number; + + Networks: ValueOf>[]; + + BytesRead: number = 0; + + BytesWrite: number = 0; + + noIOdata: boolean = false; + + constructor(data: ContainerStats) { + this.read = data.read || ''; + this.preread = data.preread || ''; + if (data?.memory_stats?.privateworkingset !== undefined) { + // Windows + this.MemoryUsage = data?.memory_stats?.privateworkingset; + this.MemoryCache = 0; + this.NumProcs = data.num_procs || 0; + this.isWindows = true; + } + // Linux + else if ( + data?.memory_stats?.stats === undefined || + data?.memory_stats?.usage === undefined + ) { + this.MemoryUsage = 0; + this.MemoryCache = 0; + } else { + this.MemoryCache = 0; + if (data?.memory_stats?.stats?.cache !== undefined) { + // cgroups v1 + this.MemoryCache = data.memory_stats.stats.cache; + } + this.MemoryUsage = data.memory_stats.usage - this.MemoryCache; + } + this.PreviousCPUTotalUsage = + data?.precpu_stats?.cpu_usage?.total_usage || 0; + this.PreviousCPUSystemUsage = data?.precpu_stats?.system_cpu_usage || 0; + this.CurrentCPUTotalUsage = data?.cpu_stats?.cpu_usage?.total_usage || 0; + this.CurrentCPUSystemUsage = data?.cpu_stats?.system_cpu_usage || 0; + this.CPUCores = 1; + + this.CPUCores = + data?.cpu_stats?.cpu_usage?.percpu_usage?.length ?? + data?.cpu_stats?.online_cpus ?? + 1; + + this.Networks = values(data.networks); + + if ( + data.blkio_stats !== undefined && + data.blkio_stats.io_service_bytes_recursive !== null + ) { + // TODO: take care of multiple block devices + let readData = data?.blkio_stats?.io_service_bytes_recursive?.find( + (d) => d.op === 'Read' + ); + if (readData === undefined) { + // try the cgroups v2 version + readData = data?.blkio_stats?.io_service_bytes_recursive?.find( + (d) => d.op === 'read' + ); + } + if (readData !== undefined) { + this.BytesRead = readData.value; + } + let writeData = data?.blkio_stats?.io_service_bytes_recursive?.find( + (d) => d.op === 'Write' + ); + if (writeData === undefined) { + // try the cgroups v2 version + writeData = data?.blkio_stats?.io_service_bytes_recursive?.find( + (d) => d.op === 'write' + ); + } + if (writeData !== undefined) { + this.BytesWrite = writeData.value; + } + } else { + // no IO related data is available + this.noIOdata = true; + } + } +} diff --git a/app/docker/models/event.js b/app/docker/models/event.js deleted file mode 100644 index 2ac0de604..000000000 --- a/app/docker/models/event.js +++ /dev/null @@ -1,174 +0,0 @@ -function createEventDetails(event) { - var eventAttr = event.Actor.Attributes; - var details = ''; - - var action = event.Action; - var extra = ''; - var hasColon = action.indexOf(':'); - if (hasColon != -1) { - extra = action.substring(hasColon); - action = action.substring(0, hasColon); - } - - switch (event.Type) { - case 'container': - switch (action) { - case 'stop': - details = 'Container ' + eventAttr.name + ' stopped'; - break; - case 'destroy': - details = 'Container ' + eventAttr.name + ' deleted'; - break; - case 'create': - details = 'Container ' + eventAttr.name + ' created'; - break; - case 'start': - details = 'Container ' + eventAttr.name + ' started'; - break; - case 'kill': - details = 'Container ' + eventAttr.name + ' killed'; - break; - case 'die': - details = 'Container ' + eventAttr.name + ' exited with status code ' + eventAttr.exitCode; - break; - case 'commit': - details = 'Container ' + eventAttr.name + ' committed'; - break; - case 'restart': - details = 'Container ' + eventAttr.name + ' restarted'; - break; - case 'pause': - details = 'Container ' + eventAttr.name + ' paused'; - break; - case 'unpause': - details = 'Container ' + eventAttr.name + ' unpaused'; - break; - case 'attach': - details = 'Container ' + eventAttr.name + ' attached'; - break; - case 'detach': - details = 'Container ' + eventAttr.name + ' detached'; - break; - case 'copy': - details = 'Container ' + eventAttr.name + ' copied'; - break; - case 'export': - details = 'Container ' + eventAttr.name + ' exported'; - break; - case 'health_status': - details = 'Container ' + eventAttr.name + ' executed health status'; - break; - case 'oom': - details = 'Container ' + eventAttr.name + ' goes in out of memory'; - break; - case 'rename': - details = 'Container ' + eventAttr.name + ' renamed'; - break; - case 'resize': - details = 'Container ' + eventAttr.name + ' resized'; - break; - case 'top': - details = 'Showed running processes for container ' + eventAttr.name; - break; - case 'update': - details = 'Container ' + eventAttr.name + ' updated'; - break; - case 'exec_create': - details = 'Exec instance created'; - break; - case 'exec_start': - details = 'Exec instance started'; - break; - case 'exec_die': - details = 'Exec instance exited'; - break; - default: - details = 'Unsupported event'; - } - break; - case 'image': - switch (action) { - case 'delete': - details = 'Image deleted'; - break; - case 'import': - details = 'Image ' + event.Actor.ID + ' imported'; - break; - case 'load': - details = 'Image ' + event.Actor.ID + ' loaded'; - break; - case 'tag': - details = 'New tag created for ' + eventAttr.name; - break; - case 'untag': - details = 'Image untagged'; - break; - case 'save': - details = 'Image ' + event.Actor.ID + ' saved'; - break; - case 'pull': - details = 'Image ' + event.Actor.ID + ' pulled'; - break; - case 'push': - details = 'Image ' + event.Actor.ID + ' pushed'; - break; - default: - details = 'Unsupported event'; - } - break; - case 'network': - switch (action) { - case 'create': - details = 'Network ' + eventAttr.name + ' created'; - break; - case 'destroy': - details = 'Network ' + eventAttr.name + ' deleted'; - break; - case 'remove': - details = 'Network ' + eventAttr.name + ' removed'; - break; - case 'connect': - details = 'Container connected to ' + eventAttr.name + ' network'; - break; - case 'disconnect': - details = 'Container disconnected from ' + eventAttr.name + ' network'; - break; - default: - details = 'Unsupported event'; - } - break; - case 'volume': - switch (action) { - case 'create': - details = 'Volume ' + event.Actor.ID + ' created'; - break; - case 'destroy': - details = 'Volume ' + event.Actor.ID + ' deleted'; - break; - case 'mount': - details = 'Volume ' + event.Actor.ID + ' mounted'; - break; - case 'unmount': - details = 'Volume ' + event.Actor.ID + ' unmounted'; - break; - default: - details = 'Unsupported event'; - } - break; - default: - details = 'Unsupported event'; - } - return details + extra; -} - -export function EventViewModel(data) { - // Type, Action, Actor unavailable in Docker < 1.10 - this.Time = data.time; - if (data.Type) { - this.Type = data.Type; - this.Details = createEventDetails(data); - } else { - this.Type = data.status; - this.Details = data.from; - } -} diff --git a/app/docker/models/event.ts b/app/docker/models/event.ts new file mode 100644 index 000000000..95b171140 --- /dev/null +++ b/app/docker/models/event.ts @@ -0,0 +1,134 @@ +import { EventMessage } from 'docker-types/generated/1.41'; + +type EventType = NonNullable; +type Action = string; + +type Attributes = { + id: string; + name: string; + exitCode: string; +}; + +type EventToTemplateMap = Record; +type ActionToTemplateMap = Record; +type TemplateBuilder = (attr: Attributes) => string; + +/** + * { + * [EventType]: { + * [Action]: TemplateBuilder, + * [Action]: TemplateBuilder + * }, + * [EventType]: { + * [Action]: TemplateBuilder, + * } + * } + * + * EventType are known and defined by Docker specs + * Action are unknown and specific for each EventType + */ +const templates: EventToTemplateMap = { + builder: {}, + config: {}, + container: { + stop: ({ name }) => `Container ${name} stopped`, + destroy: ({ name }) => `Container ${name} deleted`, + create: ({ name }) => `Container ${name} created`, + start: ({ name }) => `Container ${name} started`, + kill: ({ name }) => `Container ${name} killed`, + die: ({ name, exitCode }) => + `Container ${name} exited with status code ${exitCode}`, + commit: ({ name }) => `Container ${name} committed`, + restart: ({ name }) => `Container ${name} restarted`, + pause: ({ name }) => `Container ${name} paused`, + unpause: ({ name }) => `Container ${name} unpaused`, + attach: ({ name }) => `Container ${name} attached`, + detach: ({ name }) => `Container ${name} detached`, + copy: ({ name }) => `Container ${name} copied`, + export: ({ name }) => `Container ${name} exported`, + health_status: ({ name }) => `Container ${name} executed health status`, + oom: ({ name }) => `Container ${name} goes in out of memory`, + rename: ({ name }) => `Container ${name} renamed`, + resize: ({ name }) => `Container ${name} resized`, + top: ({ name }) => `Showed running processes for container ${name}`, + update: ({ name }) => `Container ${name} updated`, + exec_create: () => `Exec instance created`, + exec_start: () => `Exec instance started`, + exec_die: () => `Exec instance exited`, + }, + daemon: {}, + image: { + delete: () => `Image deleted`, + import: ({ id }) => `Image ${id} imported`, + load: ({ id }) => `Image ${id} loaded`, + tag: ({ name }) => `New tag created for ${name}`, + untag: () => `Image untagged`, + save: ({ id }) => `Image ${id} saved`, + pull: ({ id }) => `Image ${id} pulled`, + push: ({ id }) => `Image ${id} pushed`, + }, + network: { + create: ({ name }) => `Network ${name} created`, + destroy: ({ name }) => `Network ${name} deleted`, + remove: ({ name }) => `Network ${name} removed`, + connect: ({ name }) => `Container connected to ${name} network`, + disconnect: ({ name }) => `Container disconnected from ${name} network`, + prune: () => `Networks pruned`, + }, + node: {}, + plugin: {}, + secret: {}, + service: {}, + volume: { + create: ({ id }) => `Volume ${id} created`, + destroy: ({ id }) => `Volume ${id} deleted`, + mount: ({ id }) => `Volume ${id} mounted`, + unmount: ({ id }) => `Volume ${id} unmounted`, + }, +}; + +function createEventDetails(event: EventMessage) { + const eventType = event.Type ?? ''; + + // An action can be `action:extra` + // For example `docker exec -it CONTAINER sh` + // Generates the action `exec_create: sh` + let extra = ''; + let action = event.Action ?? ''; + const hasColon = action?.indexOf(':') ?? -1; + if (hasColon !== -1) { + extra = action?.substring(hasColon) ?? ''; + action = action?.substring(0, hasColon); + } + + const attr: Attributes = { + id: event.Actor?.ID || '', + name: event.Actor?.Attributes?.name || '', + exitCode: event.Actor?.Attributes?.exitCode || '', + }; + + // Event types are defined by the docker API specs + // Each event has it own set of actions, which a unknown/not defined by specs + // If the received event or action has no builder associated to it + // We consider the event unsupported and we provide the raw data + const detailsBuilder = templates[eventType as EventType]?.[action]; + const details = detailsBuilder + ? detailsBuilder(attr) + : `Unsupported event: ${eventType} / ${action}`; + + return details + extra; +} + +export class EventViewModel { + Time: EventMessage['time']; + + Type: EventMessage['Type']; + + Details: string; + + constructor(data: EventMessage) { + this.Time = data.time; + this.Type = data.Type; + this.Details = createEventDetails(data); + } +} diff --git a/app/docker/models/image.js b/app/docker/models/image.js deleted file mode 100644 index c3febe04c..000000000 --- a/app/docker/models/image.js +++ /dev/null @@ -1,45 +0,0 @@ -export function ImageViewModel(data) { - this.Id = data.Id; - this.Tag = data.Tag; - this.Repository = data.Repository; - this.Created = data.Created; - this.Checked = false; - this.RepoTags = data.RepoTags; - if ((!this.RepoTags || this.RepoTags.length === 0) && data.RepoDigests) { - this.RepoTags = []; - for (var i = 0; i < data.RepoDigests.length; i++) { - var digest = data.RepoDigests[i]; - var repository = digest.substring(0, digest.indexOf('@')); - this.RepoTags.push(repository + ':'); - } - } - - this.Size = data.Size; - this.Used = data.Used; - - if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) { - this.NodeName = data.Portainer.Agent.NodeName; - } - this.Labels = data.Labels; -} - -export function ImageBuildModel(data) { - this.hasError = false; - var buildLogs = []; - - for (var i = 0; i < data.length; i++) { - var line = data[i]; - - if (line.stream) { - line = line.stream.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); - buildLogs.push(line); - } - - if (line.errorDetail) { - buildLogs.push(line.errorDetail.message); - this.hasError = true; - } - } - - this.buildLogs = buildLogs; -} diff --git a/app/docker/models/image.ts b/app/docker/models/image.ts new file mode 100644 index 000000000..f3f777bb2 --- /dev/null +++ b/app/docker/models/image.ts @@ -0,0 +1,47 @@ +import { ImageSummary } from 'docker-types/generated/1.41'; + +import { PortainerResponse } from '@/react/docker/types'; + +export type ImageId = ImageSummary['Id']; +export type ImageName = string; + +/** + * Partial copy of ImageSummary + */ +export class ImageViewModel { + Id: ImageId; + + Created: ImageSummary['Created']; + + RepoTags: ImageSummary['RepoTags']; + + Size: ImageSummary['Size']; + + Labels: ImageSummary['Labels']; + + // internal + + NodeName: string; + + Used: boolean = false; + + constructor(data: PortainerResponse, used: boolean = false) { + this.Id = data.Id; + // this.Tag = data.Tag; // doesn't seem to be used? + // this.Repository = data.Repository; // doesn't seem to be used? + this.Created = data.Created; + this.RepoTags = data.RepoTags; + if ((!this.RepoTags || this.RepoTags.length === 0) && data.RepoDigests) { + this.RepoTags = []; + data.RepoDigests.forEach((digest) => { + const repository = digest.substring(0, digest.indexOf('@')); + this.RepoTags.push(`${repository}:`); + }); + } + + this.Size = data.Size; + this.NodeName = data.Portainer?.Agent?.NodeName || ''; + this.Labels = data.Labels; + this.Used = used; + } +} diff --git a/app/docker/models/imageDetails.js b/app/docker/models/imageDetails.js deleted file mode 100644 index 9c02bd8fd..000000000 --- a/app/docker/models/imageDetails.js +++ /dev/null @@ -1,27 +0,0 @@ -export function ImageDetailsViewModel(data) { - this.Id = data.Id; - this.Tag = data.Tag; - this.Parent = data.Parent; - this.Repository = data.Repository; - this.Created = data.Created; - this.Checked = false; - this.RepoTags = data.RepoTags; - this.Size = data.Size; - this.DockerVersion = data.DockerVersion; - this.Os = data.Os; - this.Architecture = data.Architecture; - this.Author = data.Author; - this.Command = data.Config.Cmd; - - let config = {}; - if (data.Config) { - config = data.Config; // this is part of OCI images-spec - } else if (data.ContainerConfig != null) { - config = data.ContainerConfig; // not OCI ; has been removed in Docker 26 (API v1.45) along with .Container - } - this.Entrypoint = config.Entrypoint ? config.Entrypoint : ''; - this.ExposedPorts = config.ExposedPorts ? Object.keys(config.ExposedPorts) : []; - this.Volumes = config.Volumes ? Object.keys(config.Volumes) : []; - this.Env = config.Env ? config.Env : []; - this.Labels = config.Labels; -} diff --git a/app/docker/models/imageDetails.ts b/app/docker/models/imageDetails.ts new file mode 100644 index 000000000..8cea5d826 --- /dev/null +++ b/app/docker/models/imageDetails.ts @@ -0,0 +1,70 @@ +import { ImageInspect } from 'docker-types/generated/1.41'; + +type ImageInspectConfig = NonNullable; + +export class ImageDetailsViewModel { + Id: ImageInspect['Id']; + + Parent: ImageInspect['Parent']; + + Created: ImageInspect['Created']; + + RepoTags: ImageInspect['RepoTags']; + + Size: ImageInspect['Size']; + + DockerVersion: ImageInspect['DockerVersion']; + + Os: ImageInspect['Os']; + + Architecture: ImageInspect['Architecture']; + + Author: ImageInspect['Author']; + + // Config sub fields + + Command: ImageInspectConfig['Cmd']; + + Entrypoint: Required; + + ExposedPorts: Required; + + Volumes: Required['Volumes']; + + Env: Required['Env']; + + Labels: ImageInspectConfig['Labels']; + + // computed fields + + Used: boolean = false; + + constructor(data: ImageInspect) { + this.Id = data.Id; + // this.Tag = data.Tag; // doesn't seem to be used? + this.Parent = data.Parent; + this.Created = data.Created; + // this.Repository = data.Repository; // doesn't seem to be used? + this.RepoTags = data.RepoTags; + this.Size = data.Size; + this.DockerVersion = data.DockerVersion; + this.Os = data.Os; + this.Architecture = data.Architecture; + this.Author = data.Author; + this.Command = data.Config?.Cmd; + + let config: ImageInspect['Config'] = {}; + if (data.Config) { + config = data.Config; // this is part of OCI images-spec + } else if (data.ContainerConfig) { + config = data.ContainerConfig; // not OCI ; has been removed in Docker 26 (API v1.45) along with .Container + } + this.Entrypoint = config.Entrypoint ?? ['']; + this.ExposedPorts = config.ExposedPorts + ? Object.keys(config.ExposedPorts) + : []; + this.Volumes = config.Volumes ? Object.keys(config.Volumes) : []; + this.Env = config.Env ?? []; + this.Labels = config.Labels; + } +} diff --git a/app/docker/models/imageLayer.js b/app/docker/models/imageLayer.js deleted file mode 100644 index 4c4f79e99..000000000 --- a/app/docker/models/imageLayer.js +++ /dev/null @@ -1,9 +0,0 @@ -export function ImageLayerViewModel(order, data) { - this.Order = order; - this.Id = data.Id; - this.Created = data.Created; - this.CreatedBy = data.CreatedBy; - this.Size = data.Size; - this.Comment = data.Comment; - this.Tags = data.Tags; -} diff --git a/app/docker/models/imageLayer.ts b/app/docker/models/imageLayer.ts new file mode 100644 index 000000000..956ed364d --- /dev/null +++ b/app/docker/models/imageLayer.ts @@ -0,0 +1,27 @@ +import { ImageLayer } from '@/react/docker/proxy/queries/images/useImageHistory'; + +export class ImageLayerViewModel implements ImageLayer { + Id: ImageLayer['Id']; + + Created: ImageLayer['Created']; + + CreatedBy: ImageLayer['CreatedBy']; + + Size: ImageLayer['Size']; + + Comment: ImageLayer['Comment']; + + Tags: ImageLayer['Tags']; + + constructor( + public Order: number, + data: ImageLayer + ) { + this.Id = data.Id; + this.Created = data.Created; + this.CreatedBy = data.CreatedBy; + this.Size = data.Size; + this.Comment = data.Comment; + this.Tags = data.Tags; + } +} diff --git a/app/docker/models/network.ts b/app/docker/models/network.ts index 56b373c2f..95ff03b22 100644 --- a/app/docker/models/network.ts +++ b/app/docker/models/network.ts @@ -1,8 +1,21 @@ import { IPAM, Network, NetworkContainer } from 'docker-types/generated/1.41'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; -import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn'; -import { PortainerMetadata } from '@/react/docker/types'; +import { IResource } from '@/react/docker/components/datatables/createOwnershipColumn'; +import { PortainerResponse } from '@/react/docker/types'; + +// TODO later: aggregate NetworkViewModel and DockerNetwork types +// +// type MacvlanNetwork = { +// ConfigFrom?: { Network: string }; +// ConfigOnly?: boolean; +// }; +// +// type NetworkViewModel = Network & { +// StackName?: string; +// NodeName?: string; +// ResourceControl?: ResourceControlViewModel; +// } & MacvlanNetwork; export class NetworkViewModel implements IResource { Id: string; @@ -38,8 +51,7 @@ export class NetworkViewModel implements IResource { ResourceControl?: ResourceControlViewModel; constructor( - data: Network & { - Portainer?: PortainerMetadata; + data: PortainerResponse & { ConfigFrom?: { Network: string }; ConfigOnly?: boolean; } diff --git a/app/docker/models/node.ts b/app/docker/models/node.ts index d0abfbc79..df53961bd 100644 --- a/app/docker/models/node.ts +++ b/app/docker/models/node.ts @@ -10,8 +10,6 @@ import { ResourceObject, } from 'docker-types/generated/1.41'; -import { WithRequiredProperty } from '@/types'; - export class NodeViewModel { Model: Node; @@ -55,7 +53,7 @@ export class NodeViewModel { Status: NodeStatus['State']; - Addr: WithRequiredProperty['Addr'] = ''; + Addr: Required['Addr'] = ''; Leader: ManagerStatus['Leader']; diff --git a/app/docker/models/plugin.js b/app/docker/models/plugin.js deleted file mode 100644 index 3d3ad18d6..000000000 --- a/app/docker/models/plugin.js +++ /dev/null @@ -1,9 +0,0 @@ -// This model is based on https://github.com/moby/moby/blob/0ac25dfc751fa4304ab45afd5cd8705c2235d101/api/types/plugin.go#L8-L31 -// instead of the official documentation. -// See: https://github.com/moby/moby/issues/34241 -export function PluginViewModel(data) { - this.Id = data.Id; - this.Name = data.Name; - this.Enabled = data.Enabled; - this.Config = data.Config; -} diff --git a/app/docker/models/secret.ts b/app/docker/models/secret.ts index 5da084355..a9f4c94f8 100644 --- a/app/docker/models/secret.ts +++ b/app/docker/models/secret.ts @@ -1,8 +1,8 @@ import { Secret } from 'docker-types/generated/1.41'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; -import { PortainerMetadata } from '@/react/docker/types'; -import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { PortainerResponse } from '@/react/docker/types'; +import { IResource } from '@/react/docker/components/datatables/createOwnershipColumn'; export class SecretViewModel implements IResource { Id: string; @@ -19,7 +19,7 @@ export class SecretViewModel implements IResource { ResourceControl?: ResourceControlViewModel; - constructor(data: Secret & { Portainer?: PortainerMetadata }) { + constructor(data: PortainerResponse) { this.Id = data.ID || ''; this.CreatedAt = data.CreatedAt || ''; this.UpdatedAt = data.UpdatedAt || ''; diff --git a/app/docker/models/service.ts b/app/docker/models/service.ts index 23fbda1fa..1faebf869 100644 --- a/app/docker/models/service.ts +++ b/app/docker/models/service.ts @@ -9,15 +9,13 @@ import { } 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 { PortainerResponse } from '@/react/docker/types'; import { TaskViewModel } from './task'; -type ContainerSpec = WithRequiredProperty< - TaskSpec, - 'ContainerSpec' ->['ContainerSpec']; +type ContainerSpec = Required['ContainerSpec']; + +export type ServiceId = string; export class ServiceViewModel { Model: Service; @@ -140,7 +138,7 @@ export class ServiceViewModel { ResourceControl?: ResourceControlViewModel; - constructor(data: Service & { Portainer?: PortainerMetadata }) { + constructor(data: PortainerResponse) { this.Model = data; this.Id = data.ID || ''; this.Tasks = []; diff --git a/app/docker/models/swarm.js b/app/docker/models/swarm.js deleted file mode 100644 index a7e66cbd3..000000000 --- a/app/docker/models/swarm.js +++ /dev/null @@ -1,3 +0,0 @@ -export function SwarmViewModel(data) { - this.Id = data.ID; -} diff --git a/app/docker/models/task.ts b/app/docker/models/task.ts index c67a41171..fb849d6d6 100644 --- a/app/docker/models/task.ts +++ b/app/docker/models/task.ts @@ -1,25 +1,27 @@ -import { Task, TaskSpec, TaskState } from 'docker-types/generated/1.41'; +import { Task } from 'docker-types/generated/1.41'; + +import { DeepPick } from '@/types/deepPick'; export class TaskViewModel { - Id: string; + Id: NonNullable; - Created: string; + Created: NonNullable; - Updated: string; + Updated: NonNullable; - Slot: number; + Slot: NonNullable; - Spec?: TaskSpec; + Spec?: Task['Spec']; - Status: Task['Status']; + Status?: Task['Status']; - DesiredState: TaskState; + DesiredState: NonNullable; - ServiceId: string; + ServiceId: NonNullable; - NodeId: string; + NodeId: NonNullable; - ContainerId: string = ''; + ContainerId: DeepPick; constructor(data: Task) { this.Id = data.ID || ''; diff --git a/app/docker/models/volume.ts b/app/docker/models/volume.ts index 260011504..cd55c15b6 100644 --- a/app/docker/models/volume.ts +++ b/app/docker/models/volume.ts @@ -1,33 +1,33 @@ import { Volume } from 'docker-types/generated/1.41'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; -import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn'; -import { PortainerMetadata } from '@/react/docker/types'; +import { IResource } from '@/react/docker/components/datatables/createOwnershipColumn'; +import { PortainerResponse } from '@/react/docker/types'; export class VolumeViewModel implements IResource { - Id: string; + Id: Volume['Name']; - CreatedAt: string | undefined; + CreatedAt?: Volume['CreatedAt']; - Driver: string; + Driver: Volume['Driver']; - Options: Record; + Options: Volume['Options']; - Labels: Record; + Labels: Volume['Labels']; - StackName?: string; + Mountpoint: Volume['Mountpoint']; - Mountpoint: string; + // Portainer properties ResourceId?: string; NodeName?: string; + StackName?: string; + ResourceControl?: ResourceControlViewModel; - constructor( - data: Volume & { Portainer?: PortainerMetadata; ResourceID?: string } - ) { + constructor(data: PortainerResponse & { ResourceID?: string }) { this.Id = data.Name; this.CreatedAt = data.CreatedAt; this.Driver = data.Driver; diff --git a/app/docker/rest/build.js b/app/docker/rest/build.js deleted file mode 100644 index 1d5b1606e..000000000 --- a/app/docker/rest/build.js +++ /dev/null @@ -1,28 +0,0 @@ -import { API_ENDPOINT_ENDPOINTS } from '@/constants'; -import { jsonObjectsToArrayHandler } from './response/handlers'; - -angular.module('portainer.docker').factory('Build', [ - '$resource', - function BuildFactory($resource) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/build', - {}, - { - buildImage: { - method: 'POST', - ignoreLoadingBar: true, - transformResponse: jsonObjectsToArrayHandler, - isArray: true, - headers: { 'Content-Type': 'application/x-tar' }, - }, - buildImageOverride: { - method: 'POST', - ignoreLoadingBar: true, - transformResponse: jsonObjectsToArrayHandler, - isArray: true, - }, - } - ); - }, -]); diff --git a/app/docker/rest/commit.js b/app/docker/rest/commit.js deleted file mode 100644 index d942c2f2f..000000000 --- a/app/docker/rest/commit.js +++ /dev/null @@ -1,14 +0,0 @@ -angular.module('portainer.docker').factory('Commit', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - function CommitFactory($resource, API_ENDPOINT_ENDPOINTS) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:environmentId/docker/commit', - {}, - { - commitContainer: { method: 'POST', params: { container: '@id', repo: '@repo' }, ignoreLoadingBar: true }, - } - ); - }, -]); diff --git a/app/docker/rest/config.js b/app/docker/rest/config.js deleted file mode 100644 index 168b47e5b..000000000 --- a/app/docker/rest/config.js +++ /dev/null @@ -1,19 +0,0 @@ -angular.module('portainer.docker').factory('Config', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - function ConfigFactory($resource, API_ENDPOINT_ENDPOINTS) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:environmentId/docker/configs/:id/:action', - { - environmentId: '@environmentId', - }, - { - get: { method: 'GET', params: { id: '@id' } }, - query: { method: 'GET', isArray: true }, - create: { method: 'POST', params: { action: 'create' }, ignoreLoadingBar: true }, - remove: { method: 'DELETE', params: { id: '@id' } }, - } - ); - }, -]); diff --git a/app/docker/rest/container.js b/app/docker/rest/container.js deleted file mode 100644 index d9f746b00..000000000 --- a/app/docker/rest/container.js +++ /dev/null @@ -1,73 +0,0 @@ -import { genericHandler, logsHandler } from './response/handlers'; - -angular.module('portainer.docker').factory('Container', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:environmentId/docker/containers/:id/:action', - { - name: '@name', - environmentId: '@environmentId', - }, - { - query: { - method: 'GET', - params: { all: 0, action: 'json', filters: '@filters' }, - isArray: true, - }, - get: { - method: 'GET', - params: { action: 'json' }, - }, - logs: { - method: 'GET', - params: { id: '@id', action: 'logs' }, - ignoreLoadingBar: true, - transformResponse: logsHandler, - }, - stats: { - method: 'GET', - params: { id: '@id', stream: false, action: 'stats' }, - ignoreLoadingBar: true, - }, - top: { - method: 'GET', - params: { id: '@id', action: 'top' }, - ignoreLoadingBar: true, - }, - create: { - method: 'POST', - params: { action: 'create' }, - transformResponse: genericHandler, - ignoreLoadingBar: true, - }, - exec: { - method: 'POST', - params: { id: '@id', action: 'exec' }, - transformResponse: genericHandler, - ignoreLoadingBar: true, - }, - inspect: { - method: 'GET', - params: { id: '@id', action: 'json' }, - }, - update: { - method: 'POST', - params: { id: '@id', action: 'update' }, - }, - prune: { - method: 'POST', - params: { action: 'prune', filters: '@filters' }, - }, - resize: { - method: 'POST', - params: { id: '@id', action: 'resize', h: '@height', w: '@width' }, - transformResponse: genericHandler, - ignoreLoadingBar: true, - }, - } - ); - }, -]); diff --git a/app/docker/rest/exec.js b/app/docker/rest/exec.js deleted file mode 100644 index 20c5035e4..000000000 --- a/app/docker/rest/exec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { genericHandler } from './response/handlers'; - -angular.module('portainer.docker').factory('Exec', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function ExecFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/exec/:id/:action', - { - endpointId: EndpointProvider.endpointID, - }, - { - resize: { - method: 'POST', - params: { id: '@id', action: 'resize', h: '@height', w: '@width' }, - transformResponse: genericHandler, - ignoreLoadingBar: true, - }, - } - ); - }, -]); diff --git a/app/docker/rest/image.js b/app/docker/rest/image.js deleted file mode 100644 index bd97d75a0..000000000 --- a/app/docker/rest/image.js +++ /dev/null @@ -1,57 +0,0 @@ -import { deleteImageHandler, jsonObjectsToArrayHandler } from './response/handlers'; -import { imageGetResponse } from './response/image'; - -angular.module('portainer.docker').factory('Image', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - 'HttpRequestHelper', - function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { - 'use strict'; - - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/images/:id/:action', - { - endpointId: EndpointProvider.endpointID, - }, - { - query: { method: 'GET', params: { all: 0, action: 'json' }, isArray: true }, - get: { method: 'GET', params: { action: 'json' } }, - search: { method: 'GET', params: { action: 'search' } }, - history: { method: 'GET', params: { action: 'history' }, isArray: true }, - insert: { method: 'POST', params: { id: '@id', action: 'insert' } }, - tag: { method: 'POST', params: { id: '@id', action: 'tag', force: 0, repo: '@repo' }, ignoreLoadingBar: true }, - inspect: { method: 'GET', params: { id: '@id', action: 'json' } }, - push: { - method: 'POST', - params: { action: 'push', id: '@imageName' }, - isArray: true, - transformResponse: jsonObjectsToArrayHandler, - headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader }, - ignoreLoadingBar: true, - }, - create: { - method: 'POST', - params: { action: 'create', fromImage: '@fromImage' }, - isArray: true, - transformResponse: jsonObjectsToArrayHandler, - headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader }, - ignoreLoadingBar: true, - }, - download: { - method: 'GET', - params: { action: 'get', names: '@names' }, - transformResponse: imageGetResponse, - responseType: 'blob', - ignoreLoadingBar: true, - }, - remove: { - method: 'DELETE', - params: { id: '@id', force: '@force' }, - isArray: true, - transformResponse: deleteImageHandler, - }, - } - ); - }, -]); diff --git a/app/docker/rest/network.js b/app/docker/rest/network.js deleted file mode 100644 index 017cb1bda..000000000 --- a/app/docker/rest/network.js +++ /dev/null @@ -1,44 +0,0 @@ -import { genericHandler } from './response/handlers'; - -angular.module('portainer.docker').factory('Network', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', - { - id: '@id', - endpointId: EndpointProvider.endpointID, - }, - { - query: { - method: 'GET', - isArray: true, - }, - get: { - method: 'GET', - }, - create: { - method: 'POST', - params: { action: 'create' }, - transformResponse: genericHandler, - ignoreLoadingBar: true, - }, - remove: { - method: 'DELETE', - transformResponse: genericHandler, - }, - connect: { - method: 'POST', - params: { action: 'connect' }, - }, - disconnect: { - method: 'POST', - params: { action: 'disconnect' }, - }, - } - ); - }, -]); diff --git a/app/docker/rest/node.js b/app/docker/rest/node.js deleted file mode 100644 index aa35d1a13..000000000 --- a/app/docker/rest/node.js +++ /dev/null @@ -1,20 +0,0 @@ -angular.module('portainer.docker').factory('Node', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function NodeFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/nodes/:id/:action', - { - endpointId: EndpointProvider.endpointID, - }, - { - query: { method: 'GET', isArray: true }, - get: { method: 'GET', params: { id: '@id' } }, - update: { method: 'POST', params: { id: '@id', action: 'update', version: '@version' } }, - remove: { method: 'DELETE', params: { id: '@id' } }, - } - ); - }, -]); diff --git a/app/docker/rest/plugin.js b/app/docker/rest/plugin.js deleted file mode 100644 index ed2e5784b..000000000 --- a/app/docker/rest/plugin.js +++ /dev/null @@ -1,17 +0,0 @@ -angular.module('portainer.docker').factory('Plugin', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function PluginFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/plugins/:id/:action', - { - endpointId: EndpointProvider.endpointID, - }, - { - query: { method: 'GET', isArray: true }, - } - ); - }, -]); diff --git a/app/docker/rest/response/handlers.js b/app/docker/rest/response/handlers.js deleted file mode 100644 index 4eb6e370f..000000000 --- a/app/docker/rest/response/handlers.js +++ /dev/null @@ -1,81 +0,0 @@ -function isJSONArray(jsonString) { - return Object.prototype.toString.call(jsonString) === '[object Array]'; -} - -function isJSON(jsonString) { - try { - var o = JSON.parse(jsonString); - if (o && typeof o === 'object') { - return o; - } - } catch (e) { - //empty - } - return false; -} - -// The Docker API often returns a list of JSON object. -// This handler wrap the JSON objects in an array. -// Used by the API in: Image push, Image create, Events query. -export function jsonObjectsToArrayHandler(data) { - // catching empty data helps the function not to fail and prevents unwanted error message to user. - if (!data) { - return []; - } - var str = '[' + data.replace(/\n/g, ' ').replace(/\}\s*\{/g, '}, {') + ']'; - return angular.fromJson(str); -} - -// The Docker API often returns an empty string or a valid JSON object on success (Docker 1.9 -> Docker 1.12). -// On error, it returns either an error message as a string (Docker < 1.12) or a JSON object with the field message -// container the error (Docker = 1.12) -// This handler ensure a valid JSON object is returned in any case. -// Used by the API in: container deletion, network deletion, network creation, volume creation, -// container exec, exec resize. -export function genericHandler(data) { - var response = {}; - // No data is returned when deletion is successful (Docker 1.9 -> 1.12) - if (!data) { - return response; - } - // A string is returned on failure (Docker < 1.12) - else if (!isJSON(data)) { - response.message = data; - } - // Docker 1.12 returns a valid JSON object when an error occurs - else { - response = angular.fromJson(data); - } - return response; -} - -// The Docker API returns the logs as a single string. -// This handler wraps the data in a JSON object under the "logs" property. -export function logsHandler(data) { - return { - logs: data, - }; -} - -// Image delete API returns an array on success (Docker 1.9 -> Docker 1.12). -// On error, it returns either an error message as a string (Docker < 1.12) or a JSON object with the field message -// container the error (Docker = 1.12). -// This handler returns the original array on success or a newly created array containing -// only one JSON object with the field message filled with the error message on failure. -export function deleteImageHandler(data) { - // A string is returned on failure (Docker < 1.12) - var response = []; - if (!isJSON(data)) { - response.push({ message: data }); - } - // A JSON object is returned on failure (Docker = 1.12) - else if (!isJSONArray(data)) { - var json = angular.fromJson(data); - response.push(json); - } - // An array is returned on success (Docker 1.9 -> 1.12) - else { - response = angular.fromJson(data); - } - return response; -} diff --git a/app/docker/rest/response/image.js b/app/docker/rest/response/image.js deleted file mode 100644 index 7d61e5943..000000000 --- a/app/docker/rest/response/image.js +++ /dev/null @@ -1,9 +0,0 @@ -// The get action of the Image service returns a file. -// ngResource will transform it as an array of chars. -// This functions simply creates a response object and assign -// the data to a field. -export function imageGetResponse(data) { - var response = {}; - response.file = data; - return response; -} diff --git a/app/docker/rest/secret.js b/app/docker/rest/secret.js deleted file mode 100644 index 9969a18d2..000000000 --- a/app/docker/rest/secret.js +++ /dev/null @@ -1,20 +0,0 @@ -angular.module('portainer.docker').factory('Secret', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function SecretFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/secrets/:id/:action', - { - endpointId: EndpointProvider.endpointID, - }, - { - get: { method: 'GET', params: { id: '@id' } }, - query: { method: 'GET', isArray: true }, - create: { method: 'POST', params: { action: 'create' }, ignoreLoadingBar: true }, - remove: { method: 'DELETE', params: { id: '@id' } }, - } - ); - }, -]); diff --git a/app/docker/rest/service.js b/app/docker/rest/service.js deleted file mode 100644 index f11c12214..000000000 --- a/app/docker/rest/service.js +++ /dev/null @@ -1,45 +0,0 @@ -import { logsHandler } from './response/handlers'; - -angular.module('portainer.docker').factory('Service', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - 'HttpRequestHelper', - function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/services/:id/:action', - { - endpointId: EndpointProvider.endpointID, - }, - { - get: { method: 'GET', params: { id: '@id' } }, - query: { method: 'GET', isArray: true, params: { filters: '@filters' } }, - create: { - method: 'POST', - params: { action: 'create' }, - headers: { - 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader, - version: '1.29', - }, - ignoreLoadingBar: true, - }, - update: { - method: 'POST', - params: { id: '@id', action: 'update', version: '@version', rollback: '@rollback' }, - headers: { - 'X-Registry-Auth': (config) => btoa(JSON.stringify({ registryId: config.data.registryId })), - version: '1.29', - }, - }, - remove: { method: 'DELETE', params: { id: '@id' } }, - logs: { - method: 'GET', - params: { id: '@id', action: 'logs' }, - ignoreLoadingBar: true, - transformResponse: logsHandler, - }, - } - ); - }, -]); diff --git a/app/docker/rest/swarm.js b/app/docker/rest/swarm.js deleted file mode 100644 index 0cdc1fd9a..000000000 --- a/app/docker/rest/swarm.js +++ /dev/null @@ -1,17 +0,0 @@ -angular.module('portainer.docker').factory('Swarm', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function SwarmFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/swarm', - { - endpointId: EndpointProvider.endpointID, - }, - { - get: { method: 'GET' }, - } - ); - }, -]); diff --git a/app/docker/rest/system.js b/app/docker/rest/system.js deleted file mode 100644 index 342355f90..000000000 --- a/app/docker/rest/system.js +++ /dev/null @@ -1,32 +0,0 @@ -import { jsonObjectsToArrayHandler } from './response/handlers'; - -angular.module('portainer.docker').factory('System', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function SystemFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/:action/:subAction', - { - name: '@name', - endpointId: EndpointProvider.endpointID, - }, - { - info: { - method: 'GET', - params: { action: 'info' }, - }, - version: { method: 'GET', params: { action: 'version' } }, - events: { - method: 'GET', - params: { action: 'events', since: '@since', until: '@until' }, - isArray: true, - transformResponse: jsonObjectsToArrayHandler, - }, - auth: { method: 'POST', params: { action: 'auth' } }, - dataUsage: { method: 'GET', params: { action: 'system', subAction: 'df' } }, - } - ); - }, -]); diff --git a/app/docker/rest/task.js b/app/docker/rest/task.js deleted file mode 100644 index dd818b861..000000000 --- a/app/docker/rest/task.js +++ /dev/null @@ -1,26 +0,0 @@ -import { logsHandler } from './response/handlers'; - -angular.module('portainer.docker').factory('Task', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function TaskFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/tasks/:id/:action', - { - endpointId: EndpointProvider.endpointID, - }, - { - get: { method: 'GET', params: { id: '@id' } }, - query: { method: 'GET', isArray: true, params: { filters: '@filters' } }, - logs: { - method: 'GET', - params: { id: '@id', action: 'logs' }, - ignoreLoadingBar: true, - transformResponse: logsHandler, - }, - } - ); - }, -]); diff --git a/app/docker/rest/volume.js b/app/docker/rest/volume.js deleted file mode 100644 index 0ff2571c7..000000000 --- a/app/docker/rest/volume.js +++ /dev/null @@ -1,37 +0,0 @@ -import { genericHandler } from './response/handlers'; - -angular.module('portainer.docker').factory('Volume', [ - '$resource', - 'API_ENDPOINT_ENDPOINTS', - 'EndpointProvider', - function VolumeFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - - function addVolumeNameToHeader(config) { - return config.data.Name || ''; - } - - return $resource( - API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/volumes/:id/:action', - { - endpointId: EndpointProvider.endpointID, - }, - { - query: { method: 'GET' }, - get: { method: 'GET', params: { id: '@id' } }, - create: { - method: 'POST', - params: { action: 'create' }, - transformResponse: genericHandler, - ignoreLoadingBar: true, - headers: { 'X-Portainer-VolumeName': addVolumeNameToHeader }, - }, - remove: { - method: 'DELETE', - transformResponse: genericHandler, - params: { id: '@id' }, - }, - } - ); - }, -]); diff --git a/app/docker/services/buildService.js b/app/docker/services/buildService.js index 852a5f2ae..7cf41a940 100644 --- a/app/docker/services/buildService.js +++ b/app/docker/services/buildService.js @@ -1,91 +1,65 @@ -import { ImageBuildModel } from '../models/image'; +import { + buildImageFromDockerfileContent, + buildImageFromDockerfileContentAndFiles, + buildImageFromURL, + buildImageFromUpload, +} from '@/react/docker/images/queries/useBuildImageMutation'; -angular.module('portainer.docker').factory('BuildService', [ - '$q', - 'Build', - 'FileUploadService', - function BuildServiceFactory($q, Build, FileUploadService) { - 'use strict'; - var service = {}; +import { ImageBuildModel } from '../models/build'; - service.buildImageFromUpload = function (endpointID, names, file, path) { - var deferred = $q.defer(); +angular.module('portainer.docker').factory('BuildService', BuildServiceFactory); - FileUploadService.buildImage(endpointID, names, file, path) - .then(function success(response) { - var model = new ImageBuildModel(response.data); - deferred.resolve(model); - }) - .catch(function error(err) { - deferred.reject(err); - }); +/* @ngInject */ +function BuildServiceFactory(AngularToReact) { + const { useAxios } = AngularToReact; - return deferred.promise; - }; + return { + buildImageFromUpload: useAxios(buildImageFromUploadAngularJS), // build image + buildImageFromURL: useAxios(buildImageFromURLAngularJS), // build image + buildImageFromDockerfileContent: useAxios(buildImageFromDockerfileContentAngularJS), // build image + buildImageFromDockerfileContentAndFiles: useAxios(buildImageFromDockerfileContentAndFilesAngularJS), // build image + }; - service.buildImageFromURL = function (endpointId, names, url, path) { - var params = { - endpointId, - t: names, - remote: url, - dockerfile: path, - }; + /** + * @param {EnvironmentId} environmentId + * @param {string[]} names + * @param {File} file + * @param {string} path + */ + async function buildImageFromUploadAngularJS(environmentId, names, file, path) { + const data = await buildImageFromUpload(environmentId, names, file, path); + return new ImageBuildModel(data); + } - var deferred = $q.defer(); + /** + * @param {EnvironmentId} environmentId + * @param {string[]} names + * @param {string} url + * @param {string} path + */ + async function buildImageFromURLAngularJS(environmentId, names, url, path) { + const data = await buildImageFromURL(environmentId, names, url, path); + return new ImageBuildModel(data); + } - Build.buildImage(params, {}) - .$promise.then(function success(data) { - var model = new ImageBuildModel(data); - deferred.resolve(model); - }) - .catch(function error(err) { - deferred.reject(err); - }); + /** + * @param {EnvironmentId} environmentId + * @param {string[]} names + * @param {string} content + */ + async function buildImageFromDockerfileContentAngularJS(environmentId, names, content) { + const data = await buildImageFromDockerfileContent(environmentId, names, content); + return new ImageBuildModel(data); + } - return deferred.promise; - }; - - service.buildImageFromDockerfileContent = function (endpointId, names, content) { - var params = { - endpointId, - t: names, - }; - var payload = { - content: content, - }; - - var deferred = $q.defer(); - - Build.buildImageOverride(params, payload) - .$promise.then(function success(data) { - var model = new ImageBuildModel(data); - deferred.resolve(model); - }) - .catch(function error(err) { - deferred.reject(err); - }); - - return deferred.promise; - }; - - service.buildImageFromDockerfileContentAndFiles = function (endpointID, names, content, files) { - var dockerfile = new Blob([content], { type: 'text/plain' }); - var uploadFiles = [dockerfile].concat(files); - - var deferred = $q.defer(); - - FileUploadService.buildImageFromFiles(endpointID, names, uploadFiles) - .then(function success(response) { - var model = new ImageBuildModel(response.data); - deferred.resolve(model); - }) - .catch(function error(err) { - deferred.reject(err); - }); - - return deferred.promise; - }; - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId + * @param {string[]} names + * @param {string} content + * @param {File[]} files + */ + async function buildImageFromDockerfileContentAndFilesAngularJS(environmentId, names, content, files) { + const data = await buildImageFromDockerfileContentAndFiles(environmentId, names, content, files); + return new ImageBuildModel(data); + } +} diff --git a/app/docker/services/configService.js b/app/docker/services/configService.js index ed8aa7f16..05a63a076 100644 --- a/app/docker/services/configService.js +++ b/app/docker/services/configService.js @@ -1,66 +1,37 @@ +import { getConfig } from '@/react/docker/configs/queries/useConfig'; +import { getConfigs } from '@/react/docker/configs/queries/useConfigs'; + +import { deleteConfig } from '@/react/docker/configs/queries/useDeleteConfigMutation'; +import { createConfig } from '@/react/docker/configs/queries/useCreateConfigMutation'; import { ConfigViewModel } from '../models/config'; -angular.module('portainer.docker').factory('ConfigService', [ - '$q', - 'Config', - function ConfigServiceFactory($q, Config) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('ConfigService', ConfigServiceFactory); - service.config = function (environmentId, configId) { - var deferred = $q.defer(); +/* @ngInspect */ +function ConfigServiceFactory(AngularToReact) { + const { useAxios } = AngularToReact; - Config.get({ id: configId, environmentId }) - .$promise.then(function success(data) { - var config = new ConfigViewModel(data); - deferred.resolve(config); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve config details', err: err }); - }); + return { + configs: useAxios(listConfigsAngularJS), // config list + service create + service edit + config: useAxios(getConfigAngularJS), // config create + config edit + remove: useAxios(deleteConfig), // config list + config edit + create: useAxios(createConfig), // config create + }; - return deferred.promise; - }; + /** + * @param {EnvironmentId} environmentId + */ + async function listConfigsAngularJS(environmentId) { + const data = await getConfigs(environmentId); + return data.map((c) => new ConfigViewModel(c)); + } - service.configs = function (environmentId) { - var deferred = $q.defer(); - - Config.query({ environmentId }) - .$promise.then(function success(data) { - var configs = data.map(function (item) { - return new ConfigViewModel(item); - }); - deferred.resolve(configs); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve configs', err: err }); - }); - - return deferred.promise; - }; - - service.remove = function (environmentId, configId) { - var deferred = $q.defer(); - - Config.remove({ environmentId, id: configId }) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: data.message }); - } else { - deferred.resolve(); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to remove config', err: err }); - }); - - return deferred.promise; - }; - - service.create = function (environmentId, config) { - return Config.create({ environmentId }, config).$promise; - }; - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId + * @param {ConfigId} configId + */ + async function getConfigAngularJS(environmentId, configId) { + const data = await getConfig(environmentId, configId); + return new ConfigViewModel(data); + } +} diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js index 1dc37359d..ed808f11c 100644 --- a/app/docker/services/containerService.js +++ b/app/docker/services/containerService.js @@ -9,196 +9,122 @@ import { startContainer, stopContainer, recreateContainer, + getContainerLogs, } from '@/react/docker/containers/containers.service'; -import { ContainerDetailsViewModel, ContainerStatsViewModel, ContainerViewModel } from '../models/container'; +import { getContainers } from '@/react/docker/containers/queries/useContainers'; +import { getContainer } from '@/react/docker/containers/queries/useContainer'; +import { resizeTTY } from '@/react/docker/containers/queries/useContainerResizeTTYMutation'; +import { updateContainer } from '@/react/docker/containers/queries/useUpdateContainer'; +import { createExec } from '@/react/docker/containers/queries/useCreateExecMutation'; +import { containerStats } from '@/react/docker/containers/queries/useContainerStats'; +import { containerTop } from '@/react/docker/containers/queries/useContainerTop'; +import { createOrReplace } from '@/react/docker/containers/CreateView/useCreateMutation'; + +import { ContainerDetailsViewModel } from '../models/containerDetails'; +import { ContainerStatsViewModel } from '../models/containerStats'; import { formatLogs } from '../helpers/logHelper'; angular.module('portainer.docker').factory('ContainerService', ContainerServiceFactory); /* @ngInject */ -function ContainerServiceFactory($q, Container, $timeout) { - const service = { - killContainer, - pauseContainer, - renameContainer, - restartContainer, - resumeContainer, - startContainer, - stopContainer, - recreateContainer, - remove: removeContainer, - updateRestartPolicy, - updateLimits, +function ContainerServiceFactory(AngularToReact) { + const { useAxios } = AngularToReact; + + return { + killContainer: useAxios(killContainer), // container edit + pauseContainer: useAxios(pauseContainer), // container edit + renameContainer: useAxios(renameContainer), // container edit + restartContainer: useAxios(restartContainer), // container edit + resumeContainer: useAxios(resumeContainer), // container edit + startContainer: useAxios(startContainer), // container edit + stopContainer: useAxios(stopContainer), // container edit + recreateContainer: useAxios(recreateContainer), // container edit + remove: useAxios(removeContainer), // container edit + container: useAxios(getContainerAngularJS), // container console + container edit + container stats + containers: useAxios(getContainers), // dashboard + services list + service edit + voluem edit + stackservice + stack create + stack edit + resizeTTY: useAxios(resizeTTYAngularJS), // container console + updateRestartPolicy: useAxios(updateRestartPolicyAngularJS), // container edit + createExec: useAxios(createExec), // container console + containerStats: useAxios(containerStatsAngularJS), // container stats + containerTop: useAxios(containerTop), // container stats + inspect: useAxios(getContainer), // container inspect + createAndStartContainer: useAxios(createAndStartContainer), // templates + logs: useAxios(containerLogsAngularJS), // container logs }; - service.container = function (environmentId, id) { - var deferred = $q.defer(); - - Container.get({ environmentId, id }) - .$promise.then(function success(data) { - var container = new ContainerDetailsViewModel(data); - deferred.resolve(container); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve container information', err: err }); - }); - - return deferred.promise; - }; - - service.containers = function (environmentId, all, filters) { - var deferred = $q.defer(); - Container.query({ environmentId, all, filters }) - .$promise.then(function success(data) { - var containers = data.map(function (item) { - return new ContainerViewModel(item); - }); - deferred.resolve(containers); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve containers', err: err }); - }); - - return deferred.promise; - }; - - service.resizeTTY = function (environmentId, id, width, height, timeout) { - var deferred = $q.defer(); - - $timeout(function () { - Container.resize({}, { environmentId, id, width, height }) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: 'Unable to resize tty of container ' + id, err: data.message }); - } else { - deferred.resolve(data); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to resize tty of container ' + id, err: err }); - }); - }, timeout); - - return deferred.promise; - }; - - function updateRestartPolicy(environmentId, id, restartPolicy, maximumRetryCounts) { - return Container.update({ environmentId, id }, { RestartPolicy: { Name: restartPolicy, MaximumRetryCount: maximumRetryCounts } }).$promise; + /** + * @param {EnvironmentId} environmentId + * @param {ContainerId} id + * @param {*} param2 + */ + async function getContainerAngularJS(environmentId, id, { nodeName } = {}) { + const data = await getContainer(environmentId, id, { nodeName }); + return new ContainerDetailsViewModel(data); } - function updateLimits(environmentId, id, config) { - return Container.update( - { environmentId, id }, - { - // MemorySwap: must be set - // -1: non limits, 0: treated as unset(cause update error). - MemoryReservation: config.HostConfig.MemoryReservation, - Memory: config.HostConfig.Memory, - MemorySwap: -1, - NanoCpus: config.HostConfig.NanoCpus, - } - ).$promise; + /** + * @param {EnvironmentId} environmentId + * @param {string} containerId + * @param {number} width + * @param {number} height + * @param timeout DEPRECATED: Previously used in pure AJS implementation + */ + async function resizeTTYAngularJS(environmentId, containerId, width, height) { + return resizeTTY(environmentId, containerId, { width, height }); } - service.createContainer = function (environmentId, configuration) { - var deferred = $q.defer(); - Container.create({ environmentId }, configuration) - .$promise.then(function success(data) { - deferred.resolve(data); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to create container', err: err }); - }); - return deferred.promise; - }; + /** + * @param {EnvironmentId} environmentId + * @param {ContainerId} id + * @param {RestartPolicy['Name']} restartPolicy + * @param {RestartPolicy['MaximumRetryCount']} maximumRetryCounts + */ + async function updateRestartPolicyAngularJS(environmentId, id, restartPolicy, maximumRetryCounts) { + return updateContainer(environmentId, id, { + RestartPolicy: { + Name: restartPolicy, + MaximumRetryCount: maximumRetryCounts, + }, + }); + } - service.createAndStartContainer = function (environmentId, configuration) { - var deferred = $q.defer(); - var container; - service - .createContainer(environmentId, configuration) - .then(function success(data) { - container = data; - return service.startContainer(environmentId, container.Id); - }) - .then(function success() { - deferred.resolve(container); - }) - .catch(function error(err) { - deferred.reject(err); - }); - return deferred.promise; - }; + /** + * @param {Environment} environment + * @param {*} configuration + */ + async function createAndStartContainer(environment, configuration) { + return createOrReplace({ + config: configuration, + environment, + }); + } - service.createExec = function (environmentId, execConfig) { - var deferred = $q.defer(); - - Container.exec({ environmentId }, execConfig) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: data.message, err: data.message }); - } else { - deferred.resolve(data); - } - }) - .catch(function error(err) { - deferred.reject(err); - }); - - return deferred.promise; - }; - - service.logs = function (environmentId, id, stdout, stderr, timestamps, since, tail, stripHeaders) { - var deferred = $q.defer(); - - var parameters = { - id: id, - stdout: stdout || 0, - stderr: stderr || 0, - timestamps: timestamps || 0, - since: since || 0, - tail: tail || 'all', - environmentId, - }; - - Container.logs(parameters) - .$promise.then(function success(data) { - var logs = formatLogs(data.logs, { stripHeaders, withTimestamps: !!timestamps }); - deferred.resolve(logs); - }) - .catch(function error(err) { - deferred.reject(err); - }); - - return deferred.promise; - }; - - service.containerStats = function (environmentId, id) { - var deferred = $q.defer(); - - Container.stats({ environmentId, id }) - .$promise.then(function success(data) { - var containerStats = new ContainerStatsViewModel(data); - deferred.resolve(containerStats); - }) - .catch(function error(err) { - deferred.reject(err); - }); - - return deferred.promise; - }; - - service.containerTop = function (environmentId, id) { - return Container.top({ environmentId, id }).$promise; - }; - - service.inspect = function (environmentId, id) { - return Container.inspect({ environmentId, id }).$promise; - }; - - service.prune = function (environmentId, filters) { - return Container.prune({ environmentId, filters }).$promise; - }; - - return service; + /** + * @param {EnvironmentId} environmentId + * @param {ContainerId} id + */ + async function containerStatsAngularJS(environmentId, id) { + const data = await containerStats(environmentId, id); + return new ContainerStatsViewModel(data); + } + /** + * @param {EnvironmentId} environmentId + * @param {Containerid} id + * @param {boolean?} stdout + * @param {boolean?} stderr + * @param {boolean?} timestamps + * @param {number?} since + * @param {number?} tail + * @param {boolean?} stripHeaders + */ + async function containerLogsAngularJS(environmentId, id, stdout = false, stderr = false, timestamps = false, since = 0, tail = 'all', stripHeaders) { + const data = await getContainerLogs(environmentId, id, { + since, + stderr, + stdout, + tail, + timestamps, + }); + return formatLogs(data, { stripHeaders, withTimestamps: !!timestamps }); + } } diff --git a/app/docker/services/execService.js b/app/docker/services/execService.js index a6a29490f..8bc6cd405 100644 --- a/app/docker/services/execService.js +++ b/app/docker/services/execService.js @@ -1,31 +1,23 @@ -angular.module('portainer.docker').factory('ExecService', [ - '$q', - '$timeout', - 'Exec', - function ExecServiceFactory($q, $timeout, Exec) { - 'use strict'; - var service = {}; +import { resizeTTY } from '@/react/docker/proxy/queries/useExecResizeTTYMutation'; - service.resizeTTY = function (execId, width, height, timeout) { - var deferred = $q.defer(); +angular.module('portainer.docker').factory('ExecService', ExecServiceFactory); - $timeout(function () { - Exec.resize({}, { id: execId, height: height, width: width }) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: 'Unable to resize tty of exec', err: data.message }); - } else { - deferred.resolve(data); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to resize tty of exec', err: err }); - }); - }, timeout); +/* @ngInject */ +function ExecServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; - return deferred.promise; - }; + return { + resizeTTY: useAxios(injectEnvironmentId(resizeTTYAngularJS)), + }; - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId Injected + * @param {string} execId + * @param {number} width + * @param {number} height + * @param timeout DEPRECATED: Previously used in pure AJS implementation + */ + async function resizeTTYAngularJS(environmentId, execId, width, height) { + return resizeTTY(environmentId, execId, { width, height }); + } +} diff --git a/app/docker/services/imageService.js b/app/docker/services/imageService.js index 6f5d51982..94ffe5c73 100644 --- a/app/docker/services/imageService.js +++ b/app/docker/services/imageService.js @@ -1,208 +1,91 @@ -import _ from 'lodash'; +import { groupBy } from 'lodash'; + import { getUniqueTagListFromImages } from '@/react/docker/images/utils'; +import { getImage } from '@/react/docker/proxy/queries/images/useImage'; +import { parseAxiosError } from '@/portainer/services/axios'; +import { getImages } from '@/react/docker/proxy/queries/images/useImages'; +import { getContainers } from '@/react/docker/containers/queries/useContainers'; +import { getImageHistory } from '@/react/docker/proxy/queries/images/useImageHistory'; +import { pullImage } from '@/react/docker/images/queries/usePullImageMutation'; +import { pushImage } from '@/react/docker/images/queries/usePushImageMutation'; +import { removeImage } from '@/react/docker/proxy/queries/images/useRemoveImageMutation'; +import { tagImage } from '@/react/docker/proxy/queries/images/useTagImageMutation'; +import { downloadImages } from '@/react/docker/proxy/queries/images/useDownloadImages'; +import { uploadImages } from '@/react/docker/proxy/queries/images/useUploadImageMutation'; + import { ImageViewModel } from '../models/image'; import { ImageDetailsViewModel } from '../models/imageDetails'; import { ImageLayerViewModel } from '../models/imageLayer'; -angular.module('portainer.docker').factory('ImageService', [ - '$q', - 'Image', - 'ImageHelper', - 'RegistryService', - 'HttpRequestHelper', - 'ContainerService', - 'FileUploadService', - function ImageServiceFactory($q, Image, ImageHelper, RegistryService, HttpRequestHelper, ContainerService, FileUploadService) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('ImageService', ImageServiceFactory); - service.image = function (imageId) { - var deferred = $q.defer(); - Image.get({ id: imageId }) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: data.message }); - } else { - var image = new ImageDetailsViewModel(data); - deferred.resolve(image); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve image details', err: err }); - }); - return deferred.promise; - }; +/* @ngInject */ +function ImageServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; - service.images = function ({ environmentId, withUsage } = {}) { - var deferred = $q.defer(); + return { + image: useAxios(injectEnvironmentId(imageAngularJS)), // container console + image edit + images: useAxios(injectEnvironmentId(imagesAngularJS)), // por image registry controller + dashboard + service edit + history: useAxios(injectEnvironmentId(historyAngularJS)), // image edit + pushImage: useAxios(injectEnvironmentId(pushImageAngularJS)), // image edit + pullImage: useAxios(injectEnvironmentId(pullImageAngularJS)), // images list + image edit + templates list + tagImage: useAxios(injectEnvironmentId(tagImage)), // image edit + image import + downloadImages: useAxios(injectEnvironmentId(downloadImages)), // image list + image edit + uploadImage: useAxios(injectEnvironmentId(uploadImages)), // image import + deleteImage: useAxios(injectEnvironmentId(removeImage)), // image list + image edit + getUniqueTagListFromImages, // por image registry controller + service edit + }; - $q.all({ - containers: withUsage ? ContainerService.containers(environmentId, 1) : [], - images: Image.query({}).$promise, - }) - .then(function success(data) { - var containers = data.containers; - const containerByImageId = _.groupBy(containers, 'ImageID'); + async function imageAngularJS(environmentId, imageId) { + const image = await getImage(environmentId, imageId); + return new ImageDetailsViewModel(image); + } - var images = data.images.map(function (item) { - item.Used = !!containerByImageId[item.Id] && containerByImageId[item.Id].length > 0; - return new ImageViewModel(item); - }); - - deferred.resolve(images); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve images', err: err }); - }); - return deferred.promise; - }; - - service.history = function (imageId) { - var deferred = $q.defer(); - Image.history({ id: imageId }) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: data.message }); - } else { - var layers = []; - var order = data.length; - angular.forEach(data, function (imageLayer) { - layers.push(new ImageLayerViewModel(order, imageLayer)); - order--; - }); - deferred.resolve(layers); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve image details', err: err }); - }); - return deferred.promise; - }; - - service.pushImage = pushImage; - /** - * - * @param {PorImageRegistryModel} registryModel - */ - function pushImage(registryModel) { - var deferred = $q.defer(); - - var authenticationDetails = registryModel.Registry.Authentication ? RegistryService.encodedCredentials(registryModel.Registry) : ''; - HttpRequestHelper.setRegistryAuthenticationHeader(authenticationDetails); - - const imageConfiguration = ImageHelper.createImageConfigForContainer(registryModel); - - Image.push({ imageName: imageConfiguration.fromImage }) - .$promise.then(function success(data) { - if (data[data.length - 1].error) { - deferred.reject({ msg: data[data.length - 1].error }); - } else { - deferred.resolve(); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to push image tag', err: err }); - }); - return deferred.promise; + async function imagesAngularJS(environmentId, withUsage) { + try { + const [containers, images] = await Promise.all([withUsage ? getContainers(environmentId) : [], getImages(environmentId)]); + const containerByImageId = groupBy(containers, 'ImageID'); + return images.map((item) => new ImageViewModel(item, !!containerByImageId[item.Id] && containerByImageId[item.Id].length > 0)); + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve images'); } + } - /** - * PULL IMAGE - */ - - function pullImageAndIgnoreErrors(imageConfiguration) { - var deferred = $q.defer(); - - Image.create({}, imageConfiguration) - .$promise.catch(() => { - // left empty to ignore errors - }) - .finally(function final() { - deferred.resolve(); - }); - - return deferred.promise; + async function historyAngularJS(environmentId, imageId) { + try { + const layers = await getImageHistory(environmentId, imageId); + return layers.reverse().map((layer, idx) => new ImageLayerViewModel(idx, layer)); + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve image history'); } + } - function pullImageAndAcknowledgeErrors(imageConfiguration) { - var deferred = $q.defer(); + /** + * type PorImageRegistryModel = { + * UseRegistry: bool; + * Registry?: Registry; + * Image: string; + * } + */ - Image.create({}, imageConfiguration) - .$promise.then(function success(data) { - var err = data.length > 0 && data[data.length - 1].message; - if (err) { - var detail = data[data.length - 1]; - deferred.reject({ msg: detail.message }); - } else { - deferred.resolve(data); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to pull image', err: err }); - }); + /** + * @param {EnvironmentId} environmentId Autofilled by AngularToReact + * @param {PorImageRegistryModel} registryModel + */ + async function pushImageAngularJS(environmentId, registryModel) { + const { UseRegistry, Registry, Image } = registryModel; + const registry = UseRegistry ? Registry : undefined; + return pushImage({ environmentId, image: Image, registry }); + } - return deferred.promise; - } - - service.pullImage = pullImage; - - /** - * - * @param {PorImageRegistryModel} registry - * @param {bool} ignoreErrors - */ - function pullImage(registry, ignoreErrors) { - var authenticationDetails = registry.Registry.Authentication ? RegistryService.encodedCredentials(registry.Registry) : ''; - HttpRequestHelper.setRegistryAuthenticationHeader(authenticationDetails); - - var imageConfiguration = ImageHelper.createImageConfigForContainer(registry); - - if (ignoreErrors) { - return pullImageAndIgnoreErrors(imageConfiguration); - } - return pullImageAndAcknowledgeErrors(imageConfiguration); - } - - /** - * ! PULL IMAGE - */ - - service.tagImage = function (id, image) { - return Image.tag({ id: id, repo: image }).$promise; - }; - - /** - * - * @param {Array<{tags: Array; id: string;}>} images - * @returns {Promise} - */ - service.downloadImages = function (images) { - var names = ImageHelper.getImagesNamesForDownload(images); - return Image.download(names).$promise; - }; - - service.uploadImage = function (file) { - return FileUploadService.loadImages(file); - }; - - service.deleteImage = function (id, forceRemoval) { - var deferred = $q.defer(); - Image.remove({ id: id, force: forceRemoval }) - .$promise.then(function success(data) { - if (data[0].message) { - deferred.reject({ msg: data[0].message }); - } else { - deferred.resolve(); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to remove image', err: err }); - }); - return deferred.promise; - }; - - service.getUniqueTagListFromImages = getUniqueTagListFromImages; - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId Autofilled by AngularToReact + * @param {PorImageRegistryModel} registryModel + * @param {string?} nodeName + */ + async function pullImageAngularJS(environmentId, registryModel, nodeName) { + const { UseRegistry, Registry, Image } = registryModel; + const registry = UseRegistry ? Registry : undefined; + return pullImage({ environmentId, image: Image, nodeName, registry }); + } +} diff --git a/app/docker/services/networkService.js b/app/docker/services/networkService.js index c6a1aef28..77d0d8236 100644 --- a/app/docker/services/networkService.js +++ b/app/docker/services/networkService.js @@ -1,75 +1,57 @@ +import { createNetwork } from '@/react/docker/networks/queries/useCreateNetworkMutation'; +import { getNetwork } from '@/react/docker/networks/queries/useNetwork'; +import { getNetworks } from '@/react/docker/networks/queries/useNetworks'; +import { deleteNetwork } from '@/react/docker/networks/queries/useDeleteNetworkMutation'; +import { disconnectContainer } from '@/react/docker/networks/queries/useDisconnectContainerMutation'; +import { connectContainer } from '@/react/docker/networks/queries/useConnectContainerMutation'; + import { NetworkViewModel } from '../models/network'; -angular.module('portainer.docker').factory('NetworkService', [ - '$q', - 'Network', - function NetworkServiceFactory($q, Network) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('NetworkService', NetworkServiceFactory); - service.create = function (networkConfiguration) { - var deferred = $q.defer(); +/* @ngInject */ +function NetworkServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; - Network.create(networkConfiguration) - .$promise.then(function success(data) { - deferred.resolve(data); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to create network', err: err }); - }); - return deferred.promise; - }; + return { + create: useAxios(injectEnvironmentId(createNetwork)), // create network + network: useAxios(injectEnvironmentId(networkAngularJS)), // service edit + networks: useAxios(injectEnvironmentId(networksAngularJS)), // macvlan form + container edit + dashboard + service create + service edit + custom templates list + templates list + remove: useAxios(injectEnvironmentId(deleteNetwork)), // networks list + disconnectContainer: useAxios(injectEnvironmentId(disconnectContainer)), // container edit + connectContainer: useAxios(injectEnvironmentId(connectContainerAngularJS)), // container edit + }; - service.network = function (id) { - var deferred = $q.defer(); + /** + * @param {EnvironmentId} environmentId filled by AngularToReact + * @param {NetworkId} networkId + * @param {string?} nodeName + * @returns NetworkViewModel + */ + async function networkAngularJS(environmentId, networkId, nodeName) { + const data = await getNetwork(environmentId, networkId, { nodeName }); + return new NetworkViewModel(data); + } - Network.get({ id: id }) - .$promise.then(function success(data) { - var network = new NetworkViewModel(data); - deferred.resolve(network); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve network details', err: err }); - }); + /** + * @param {EnvironmentId} environmentId filled by AngularToReact + * @param {boolean?} localNetworks + * @param {boolean?} swarmNetworks + * @param {boolean?} swarmAttachableNetworks + * @param {*} filters + * @returns NetworkViewModel[] + */ + async function networksAngularJS(environmentId, local, swarm, swarmAttachable, filters) { + const data = await getNetworks(environmentId, { local, swarm, swarmAttachable, filters }); + return data.map((n) => new NetworkViewModel(n)); + } - return deferred.promise; - }; - - service.networks = function (localNetworks, swarmNetworks, swarmAttachableNetworks, filters) { - var deferred = $q.defer(); - - Network.query({ filters: filters }) - .$promise.then(function success(data) { - var networks = data; - var filteredNetworks = networks - .filter(function (network) { - if (localNetworks && network.Scope === 'local') { - return network; - } - if (swarmNetworks && network.Scope === 'swarm') { - return network; - } - if (swarmAttachableNetworks && network.Scope === 'swarm' && network.Attachable === true) { - return network; - } - }) - .map(function (item) { - return new NetworkViewModel(item); - }); - - deferred.resolve(filteredNetworks); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve networks', err: err }); - }); - - return deferred.promise; - }; - - service.remove = function (id) { - return Network.remove({ id: id }).$promise; - }; - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId filled by AngularToReact + * @param {NetworkId} networkId + * @param {ContainerId} containerId + */ + async function connectContainerAngularJS(environmentId, networkId, containerId) { + return connectContainer({ environmentId, containerId, networkId }); + } +} diff --git a/app/docker/services/nodeService.js b/app/docker/services/nodeService.js index 42ddf7ed8..201045eef 100644 --- a/app/docker/services/nodeService.js +++ b/app/docker/services/nodeService.js @@ -1,73 +1,43 @@ +import { getNode } from '@/react/docker/proxy/queries/nodes/useNode'; +import { getNodes } from '@/react/docker/proxy/queries/nodes/useNodes'; +import { updateNode } from '@/react/docker/proxy/queries/nodes/useUpdateNodeMutation'; + import { NodeViewModel } from '../models/node'; -angular.module('portainer.docker').factory('NodeService', [ - '$q', - 'Node', - function NodeServiceFactory($q, Node) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('NodeService', NodeServiceFactory); - service.nodes = nodes; - service.node = node; - service.updateNode = updateNode; - service.getActiveManager = getActiveManager; +/* @ngInject */ +function NodeServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; - function node(id) { - var deferred = $q.defer(); - Node.get({ id: id }) - .$promise.then(function onNodeLoaded(rawNode) { - var node = new NodeViewModel(rawNode); - return deferred.resolve(node); - }) - .catch(function onFailed(err) { - deferred.reject({ msg: 'Unable to retrieve node', err: err }); - }); + return { + nodes: useAxios(injectEnvironmentId(nodesAngularJS)), // macvlan form + services list + service create + service edit + swarm visualizer + stack edit + node: useAxios(injectEnvironmentId(nodeAngularJS)), // node browser + node details + updateNode: useAxios(injectEnvironmentId(updateNodeAngularJS)), // swarm node details panel + }; - return deferred.promise; - } + /** + * @param {EnvironmentId} environmentId + * @param {NodeId} id + */ + async function nodeAngularJS(environmentId, id) { + const data = await getNode(environmentId, id); + return new NodeViewModel(data); + } - function nodes() { - var deferred = $q.defer(); + /** + * @param {EnvironmentId} environmentId + */ + async function nodesAngularJS(environmentId) { + const data = await getNodes(environmentId); + return data.map((n) => new NodeViewModel(n)); + } - Node.query({}) - .$promise.then(function success(data) { - var nodes = data.map(function (item) { - return new NodeViewModel(item); - }); - deferred.resolve(nodes); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve nodes', err: err }); - }); - - return deferred.promise; - } - - function updateNode(node) { - return Node.update({ id: node.Id, version: node.Version }, node).$promise; - } - - function getActiveManager() { - var deferred = $q.defer(); - - service - .nodes() - .then(function success(data) { - for (var i = 0; i < data.length; ++i) { - var node = data[i]; - if (node.Role === 'manager' && node.Availability === 'active' && node.Status === 'ready' && node.Addr !== '0.0.0.0') { - deferred.resolve(node); - break; - } - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve nodes', err: err }); - }); - - return deferred.promise; - } - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId + * @param {NodeSpec & { Id: string; Version: number }} nodeConfig + */ + async function updateNodeAngularJS(environmentId, nodeConfig) { + return updateNode(environmentId, nodeConfig.Id, nodeConfig, nodeConfig.Version); + } +} diff --git a/app/docker/services/pluginService.js b/app/docker/services/pluginService.js index cdf22b90a..b0d0d5e4f 100644 --- a/app/docker/services/pluginService.js +++ b/app/docker/services/pluginService.js @@ -1,76 +1,51 @@ -import _ from 'lodash-es'; -import { PluginViewModel } from '../models/plugin'; +import { isFulfilled } from '@/portainer/helpers/promise-utils'; +import { getInfo } from '@/react/docker/proxy/queries/useInfo'; +import { aggregateData, getPlugins } from '@/react/docker/proxy/queries/useServicePlugins'; -angular.module('portainer.docker').factory('PluginService', [ - '$q', - 'Plugin', - 'SystemService', - function PluginServiceFactory($q, Plugin, SystemService) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('PluginService', PluginServiceFactory); - service.plugins = function () { - var deferred = $q.defer(); - var plugins = []; +/* @ngInject */ +function PluginServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; - Plugin.query({}) - .$promise.then(function success(data) { - for (var i = 0; i < data.length; i++) { - var plugin = new PluginViewModel(data[i]); - plugins.push(plugin); - } - }) - .finally(function final() { - deferred.resolve(plugins); - }); + return { + volumePlugins: useAxios(injectEnvironmentId(volumePlugins)), // volume create + networkPlugins: useAxios(injectEnvironmentId(networksPlugins)), // network create + loggingPlugins: useAxios(injectEnvironmentId(loggingPlugins)), // service create + service edit + }; +} - return deferred.promise; - }; +/** + * @param {EnvironmentId} environmentId Injected + * @param {boolean} systemOnly + */ +async function volumePlugins(environmentId, systemOnly) { + const { systemPluginsData, pluginsData } = await getAllPlugins(environmentId); + return aggregateData(systemPluginsData, pluginsData, systemOnly, 'Volume'); +} - function servicePlugins(systemOnly, pluginType, pluginVersion) { - var deferred = $q.defer(); +/** + * @param {EnvironmentId} environmentId Injected + * @param {boolean} systemOnly + */ +async function networksPlugins(environmentId, systemOnly) { + const { systemPluginsData, pluginsData } = await getAllPlugins(environmentId); + return aggregateData(systemPluginsData, pluginsData, systemOnly, 'Network'); +} - $q.all({ - system: SystemService.plugins(), - plugins: systemOnly ? [] : service.plugins(), - }) - .then(function success(data) { - var aggregatedPlugins = []; - var systemPlugins = data.system; - var plugins = data.plugins; +/** + * @param {EnvironmentId} environmentId Injected + * @param {boolean} systemOnly + */ +async function loggingPlugins(environmentId, systemOnly) { + const { systemPluginsData, pluginsData } = await getAllPlugins(environmentId); + return aggregateData(systemPluginsData, pluginsData, systemOnly, 'Log'); +} - if (systemPlugins[pluginType]) { - aggregatedPlugins = aggregatedPlugins.concat(systemPlugins[pluginType]); - } +async function getAllPlugins(environmentId) { + const [system, plugins] = await Promise.allSettled([getInfo(environmentId), getPlugins(environmentId)]); + const systemPluginsData = isFulfilled(system) ? system.value.Plugins : undefined; + const pluginsData = isFulfilled(plugins) ? plugins.value : undefined; - for (var i = 0; i < plugins.length; i++) { - var plugin = plugins[i]; - if (plugin.Enabled && _.includes(plugin.Config.Interface.Types, pluginVersion)) { - aggregatedPlugins.push(plugin.Name); - } - } - - deferred.resolve(aggregatedPlugins); - }) - .catch(function error(err) { - deferred.reject({ msg: err.msg, err: err }); - }); - - return deferred.promise; - } - - service.volumePlugins = function (systemOnly) { - return servicePlugins(systemOnly, 'Volume', 'docker.volumedriver/1.0'); - }; - - service.networkPlugins = function (systemOnly) { - return servicePlugins(systemOnly, 'Network', 'docker.networkdriver/1.0'); - }; - - service.loggingPlugins = function (systemOnly) { - return servicePlugins(systemOnly, 'Log', 'docker.logdriver/1.0'); - }; - - return service; - }, -]); + return { systemPluginsData, pluginsData }; +} diff --git a/app/docker/services/secretService.js b/app/docker/services/secretService.js index 40268c3fb..48b72c6be 100644 --- a/app/docker/services/secretService.js +++ b/app/docker/services/secretService.js @@ -1,66 +1,37 @@ +import { getSecret } from '@/react/docker/proxy/queries/secrets/useSecret'; +import { getSecrets } from '@/react/docker/proxy/queries/secrets/useSecrets'; +import { removeSecret } from '@/react/docker/proxy/queries/secrets/useRemoveSecretMutation'; +import { createSecret } from '@/react/docker/proxy/queries/secrets/useCreateSecretMutation'; + import { SecretViewModel } from '../models/secret'; -angular.module('portainer.docker').factory('SecretService', [ - '$q', - 'Secret', - function SecretServiceFactory($q, Secret) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('SecretService', SecretServiceFactory); - service.secret = function (secretId) { - var deferred = $q.defer(); +/* @ngInject */ +function SecretServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; - Secret.get({ id: secretId }) - .$promise.then(function success(data) { - var secret = new SecretViewModel(data); - deferred.resolve(secret); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve secret details', err: err }); - }); + return { + secret: useAxios(injectEnvironmentId(secretAngularJS)), // secret edit + secrets: useAxios(injectEnvironmentId(secretsAngularJS)), // secret list + service create + service edit + remove: useAxios(injectEnvironmentId(removeSecret)), // secret list + secret edit + create: useAxios(injectEnvironmentId(createSecret)), // secret create + }; - return deferred.promise; - }; + /** + * @param {EnvironmentId} environmentId Injected + * @param {SecretId} id + */ + async function secretAngularJS(environmentId, id) { + const data = await getSecret(environmentId, id); + return new SecretViewModel(data); + } - service.secrets = function () { - var deferred = $q.defer(); - - Secret.query({}) - .$promise.then(function success(data) { - var secrets = data.map(function (item) { - return new SecretViewModel(item); - }); - deferred.resolve(secrets); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve secrets', err: err }); - }); - - return deferred.promise; - }; - - service.remove = function (secretId) { - var deferred = $q.defer(); - - Secret.remove({ id: secretId }) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: data.message }); - } else { - deferred.resolve(); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to remove secret', err: err }); - }); - - return deferred.promise; - }; - - service.create = function (secretConfig) { - return Secret.create(secretConfig).$promise; - }; - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId Injected + */ + async function secretsAngularJS(environmentId) { + const data = await getSecrets(environmentId); + return data.map((s) => new SecretViewModel(s)); + } +} diff --git a/app/docker/services/serviceService.js b/app/docker/services/serviceService.js index 749bc91c8..748e153cd 100644 --- a/app/docker/services/serviceService.js +++ b/app/docker/services/serviceService.js @@ -1,100 +1,97 @@ -import { formatLogs } from '../helpers/logHelper'; +import { removeService } from '@/react/docker/services/ListView/ServicesDatatable/useRemoveServicesMutation'; +import { createService } from '@/react/docker/services/queries/useCreateServiceMutation'; +import { getService } from '@/react/docker/services/queries/useService'; +import { getServices } from '@/react/docker/services/queries/useServices'; +import { updateService } from '@/react/docker/services/queries/useUpdateServiceMutation'; +import { getServiceLogs } from '@/react/docker/services/queries/useServiceLogs'; + import { ServiceViewModel } from '../models/service'; +import { formatLogs } from '../helpers/logHelper'; -angular.module('portainer.docker').factory('ServiceService', [ - '$q', - 'Service', - function ServiceServiceFactory($q, Service) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('ServiceService', ServiceServiceFactory); - service.services = function (filters) { - var deferred = $q.defer(); +/* @ngInject */ +function ServiceServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; - Service.query({ filters: filters ? filters : {} }) - .$promise.then(function success(data) { - var services = data.map(function (item) { - return new ServiceViewModel(item); - }); - deferred.resolve(services); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve services', err: err }); - }); + return { + services: useAxios(injectEnvironmentId(getServicesAngularJS)), // dashboard + service list + swarm visualizer + volume list + stackservice + stack edit + service: useAxios(injectEnvironmentId(getServiceAngularJS)), // service edit + task edit + remove: useAxios(injectEnvironmentId(removeServiceAngularJS)), // service edit + update: useAxios(injectEnvironmentId(updateServiceAngularJS)), // service edit + create: useAxios(injectEnvironmentId(createServiceAngularJS)), // service create + logs: useAxios(injectEnvironmentId(serviceLogsAngularJS)), // service logs + }; - return deferred.promise; - }; + /** + * @param {EnvironmentId} environmentId Injected + * @param {*} filters + */ + async function getServicesAngularJS(environmentId, filters) { + const data = await getServices(environmentId, filters); + return data.map((s) => new ServiceViewModel(s)); + } - service.service = function (id) { - var deferred = $q.defer(); + /** + * @param {EnvironmentId} environmentId Injected + * @param {ServiceId} serviceId + */ + async function getServiceAngularJS(environmentId, serviceId) { + const data = await getService(environmentId, serviceId); + return new ServiceViewModel(data); + } - Service.get({ id: id }) - .$promise.then(function success(data) { - var service = new ServiceViewModel(data); - deferred.resolve(service); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve service details', err: err }); - }); + /** + * @param {EnvironmentId} environmentId Injected + * @param {ServiceViewModel} service + */ + async function removeServiceAngularJS(environmentId, service) { + return removeService(environmentId, service.Id); + } - return deferred.promise; - }; + /** + * @param {EnvironmentId} environmentId Injected + * @param {ServiceViewModel} service + * @param {ServiceUpdateConfig} config + * @param {string?} rollback + */ + async function updateServiceAngularJS(environmentId, service, config, rollback) { + return updateService({ + environmentId, + config, + serviceId: service.Id, + version: service.Version, + registryId: config.registryId, + rollback, + }); + } - service.remove = function (service) { - var deferred = $q.defer(); + /** + * @param {EnvironmentId} environmentId Injected + * @param {Service} config + * @param {RegistryId} registryId + */ + async function createServiceAngularJS(environmentId, config, registryId) { + return createService({ environmentId, config, registryId }); + } - Service.remove({ id: service.Id }) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: data.message, err: data.message }); - } else { - deferred.resolve(); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to remove service', err: err }); - }); - - return deferred.promise; - }; - - service.update = function (serv, config, rollback) { - return service.service(serv.Id).then((data) => { - const params = { - id: serv.Id, - version: data.Version, - }; - if (rollback) { - params.rollback = rollback; - } - return Service.update(params, config).$promise; - }); - }; - - service.logs = function (id, stdout, stderr, timestamps, since, tail) { - var deferred = $q.defer(); - - var parameters = { - id: id, - stdout: stdout || 0, - stderr: stderr || 0, - timestamps: timestamps || 0, - since: since || 0, - tail: tail || 'all', - }; - - Service.logs(parameters) - .$promise.then(function success(data) { - var logs = formatLogs(data.logs, { stripHeaders: true, withTimestamps: !!timestamps }); - deferred.resolve(logs); - }) - .catch(function error(err) { - deferred.reject(err); - }); - - return deferred.promise; - }; - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId Injected + * @param {ServiceId} id + * @param {boolean?} stdout + * @param {boolean?} stderr + * @param {boolean?} timestamps + * @param {number?} since + * @param {number?} tail + */ + async function serviceLogsAngularJS(environmentId, id, stdout = false, stderr = false, timestamps = false, since = 0, tail = 'all') { + const data = await getServiceLogs(environmentId, id, { + since, + stderr, + stdout, + tail, + timestamps, + }); + return formatLogs(data, { stripHeaders: true, withTimestamps: !!timestamps }); + } +} diff --git a/app/docker/services/swarmService.js b/app/docker/services/swarmService.js index 5fa7da6c2..b6269b32c 100644 --- a/app/docker/services/swarmService.js +++ b/app/docker/services/swarmService.js @@ -1,27 +1,12 @@ -import { SwarmViewModel } from '../models/swarm'; +import { getSwarm } from '@/react/docker/proxy/queries/useSwarm'; -angular.module('portainer.docker').factory('SwarmService', [ - '$q', - 'Swarm', - function SwarmServiceFactory($q, Swarm) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('SwarmService', SwarmServiceFactory); - service.swarm = function (endpointId) { - var deferred = $q.defer(); +/* @ngInject */ +function SwarmServiceFactory(AngularToReact) { + const { useAxios } = AngularToReact; - Swarm.get(endpointId ? { endpointId } : undefined) - .$promise.then(function success(data) { - var swarm = new SwarmViewModel(data); - deferred.resolve(swarm); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve Swarm details', err: err }); - }); - - return deferred.promise; - }; - - return service; - }, -]); + return { + swarm: useAxios(getSwarm), // stack service + }; +} diff --git a/app/docker/services/systemService.js b/app/docker/services/systemService.js index b75bff0fc..ed02d2c00 100644 --- a/app/docker/services/systemService.js +++ b/app/docker/services/systemService.js @@ -1,59 +1,28 @@ +import { ping } from '@/react/docker/proxy/queries/usePing'; +import { getInfo } from '@/react/docker/proxy/queries/useInfo'; +import { getVersion } from '@/react/docker/proxy/queries/useVersion'; +import { getEvents } from '@/react/docker/proxy/queries/useEvents'; import { EventViewModel } from '../models/event'; -import { ping } from './ping'; -angular.module('portainer.docker').factory('SystemService', [ - '$q', - 'System', - function SystemServiceFactory($q, System) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('SystemService', SystemServiceFactory); - service.plugins = function () { - var deferred = $q.defer(); - System.info({}) - .$promise.then(function success(data) { - var plugins = data.Plugins; - deferred.resolve(plugins); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve plugins information from system', err: err }); - }); - return deferred.promise; - }; +/* @ngInject */ +function SystemServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; - service.info = function () { - return System.info({}).$promise; - }; + return { + info: useAxios(injectEnvironmentId(getInfo)), // dashboard + docker host view + docker host browser + swarm inspect views + stateManager (update endpoint state) + ping: useAxios(ping), // docker/__module onEnter abstract /docker subpath + version: useAxios(injectEnvironmentId(getVersion)), // docker host view + swarm inspect view + stateManager (update endpoint state) + events: useAxios(injectEnvironmentId(eventsAngularJS)), // events list + }; - service.ping = function (endpointId) { - return ping(endpointId); - }; - - service.version = function () { - return System.version({}).$promise; - }; - - service.events = function (from, to) { - var deferred = $q.defer(); - - System.events({ since: from, until: to }) - .$promise.then(function success(data) { - var events = data.map(function (item) { - return new EventViewModel(item); - }); - deferred.resolve(events); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve engine events', err: err }); - }); - - return deferred.promise; - }; - - service.dataUsage = function () { - return System.dataUsage().$promise; - }; - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId Injected + * @param {{since: string; until: string;}} param1 + */ + async function eventsAngularJS(environmentId, { since, until }) { + const data = await getEvents(environmentId, { since, until }); + return data.map((e) => new EventViewModel(e)); + } +} diff --git a/app/docker/services/taskService.js b/app/docker/services/taskService.js index 0f68183a0..f779a11f7 100644 --- a/app/docker/services/taskService.js +++ b/app/docker/services/taskService.js @@ -1,69 +1,57 @@ -import { formatLogs } from '../helpers/logHelper'; +import { getTask } from '@/react/docker/tasks/queries/useTask'; +import { getTasks } from '@/react/docker/proxy/queries/tasks/useTasks'; +import { getTaskLogs } from '@/react/docker/tasks/queries/useTaskLogs'; + import { TaskViewModel } from '../models/task'; +import { formatLogs } from '../helpers/logHelper'; -angular.module('portainer.docker').factory('TaskService', [ - '$q', - 'Task', - function TaskServiceFactory($q, Task) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('TaskService', TaskServiceFactory); - service.task = function (id) { - var deferred = $q.defer(); +/* @ngInject */ +function TaskServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; - Task.get({ id: id }) - .$promise.then(function success(data) { - var task = new TaskViewModel(data); - deferred.resolve(task); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve task details', err: err }); - }); + return { + task: useAxios(injectEnvironmentId(taskAngularJS)), // task edit + tasks: useAxios(injectEnvironmentId(tasksAngularJS)), // services list + service edit + swarm visualizer + stack edit + logs: useAxios(injectEnvironmentId(taskLogsAngularJS)), // task logs + }; - return deferred.promise; - }; + /** + * @param {EnvironmentId} environmentId Injected + * @param {TaskId} id + */ + async function taskAngularJS(environmentId, id) { + const data = await getTask(environmentId, id); + return new TaskViewModel(data); + } - service.tasks = function (filters) { - var deferred = $q.defer(); + /** + * @param {EnvironmentId} environmentId Injected + * @param {*} filters + */ + async function tasksAngularJS(environmentId, filters) { + const data = await getTasks(environmentId, filters); + return data.map((t) => new TaskViewModel(t)); + } - Task.query({ filters: filters ? filters : {} }) - .$promise.then(function success(data) { - var tasks = data.map(function (item) { - return new TaskViewModel(item); - }); - deferred.resolve(tasks); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve tasks', err: err }); - }); - - return deferred.promise; - }; - - service.logs = function (id, stdout, stderr, timestamps, since, tail) { - var deferred = $q.defer(); - - var parameters = { - id: id, - stdout: stdout || 0, - stderr: stderr || 0, - timestamps: timestamps || 0, - since: since || 0, - tail: tail || 'all', - }; - - Task.logs(parameters) - .$promise.then(function success(data) { - var logs = formatLogs(data.logs, { stripHeaders: true, withTimestamps: !!timestamps }); - deferred.resolve(logs); - }) - .catch(function error(err) { - deferred.reject(err); - }); - - return deferred.promise; - }; - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId + * @param {TaskId} id + * @param {boolean?} stdout + * @param {boolean?} stderr + * @param {boolean?} timestamps + * @param {number?} since + * @param {number?} tail + */ + async function taskLogsAngularJS(environmentId, id, stdout = false, stderr = false, timestamps = false, since = 0, tail = 'all') { + const data = await getTaskLogs(environmentId, id, { + since, + stderr, + stdout, + tail, + timestamps, + }); + return formatLogs(data, { stripHeaders: true, withTimestamps: !!timestamps }); + } +} diff --git a/app/docker/services/volumeService.js b/app/docker/services/volumeService.js index a812a86cb..b12e6195e 100644 --- a/app/docker/services/volumeService.js +++ b/app/docker/services/volumeService.js @@ -1,105 +1,89 @@ +import { getVolumes } from '@/react/docker/volumes/queries/useVolumes'; +import { getVolume } from '@/react/docker/volumes/queries/useVolume'; +import { removeVolume } from '@/react/docker/volumes/queries/useRemoveVolumeMutation'; +import { createVolume } from '@/react/docker/volumes/queries/useCreateVolumeMutation'; + import { VolumeViewModel } from '../models/volume'; -angular.module('portainer.docker').factory('VolumeService', [ - '$q', - 'Volume', - 'VolumeHelper', - function VolumeServiceFactory($q, Volume, VolumeHelper) { - 'use strict'; - var service = {}; +angular.module('portainer.docker').factory('VolumeService', VolumeServiceFactory); - service.volumes = function (params) { - var deferred = $q.defer(); - Volume.query(params) - .$promise.then(function success(data) { - var volumes = data.Volumes || []; - volumes = volumes.map(function (item) { - return new VolumeViewModel(item); - }); - deferred.resolve(volumes); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve volumes', err: err }); - }); - return deferred.promise; +/* @ngInject */ +function VolumeServiceFactory(AngularToReact) { + const { useAxios, injectEnvironmentId } = AngularToReact; + + return { + volumes: useAxios(injectEnvironmentId(volumesAngularJS)), // dashboard + service create + service edit + volume list + volume: useAxios(injectEnvironmentId(volumeAngularJS)), // volume edit + getVolumes: useAxios(injectEnvironmentId(getVolumesAngularJS)), // template list + remove: useAxios(injectEnvironmentId(removeAngularJS)), // volume list + volume edit + createVolume: useAxios(injectEnvironmentId(createAngularJS)), // volume create + createVolumeConfiguration, // volume create + createXAutoGeneratedLocalVolumes: useAxios(injectEnvironmentId(createXAutoGeneratedLocalVolumes)), // templates list + }; + + /** + * @param {EnvironmentId} environmentId Injected + * @param {Filters} filters + */ + async function volumesAngularJS(environmentId, filters) { + const data = await getVolumes(environmentId, filters); + return data.map((v) => new VolumeViewModel(v)); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {string} id + */ + async function volumeAngularJS(environmentId, id) { + const data = await getVolume(environmentId, id); + return new VolumeViewModel(data); + } + + /** + * @param {EnvironmentId} environmentId Injected + */ + async function getVolumesAngularJS(environmentId) { + return getVolumes(environmentId); + } + + /** + * @param {EnvironmentId} environmentId Injected + * @param {string} name + * @param {string?} nodeName + */ + async function removeAngularJS(environmentId, name, nodeName) { + return removeVolume(environmentId, name, { nodeName }); + } + + /** + * @param {string} name + * @param {string} driver + * @param {{name: string; value: string;}[]} driverOptions + */ + function createVolumeConfiguration(name, driver, driverOptions) { + return { + Name: name, + Driver: driver, + DriverOpts: driverOptions.reduce((res, { name, value }) => ({ ...res, [name]: value }), {}), }; + } - service.volume = function (id) { - var deferred = $q.defer(); - Volume.get({ id: id }) - .$promise.then(function success(data) { - var volume = new VolumeViewModel(data); - deferred.resolve(volume); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve volume details', err: err }); - }); - return deferred.promise; - }; + /** + * @param {EnvironmentId} environmentId Injected + * @param {VolumeConfiguration} volumeConfiguration + * @param {string?} nodeName + */ + async function createAngularJS(environmentId, volumeConfiguration, nodeName) { + const data = await createVolume(environmentId, volumeConfiguration, { nodeName }); + return new VolumeViewModel(data); + } - service.getVolumes = function () { - return Volume.query({}).$promise; - }; - - service.remove = function (volume) { - var deferred = $q.defer(); - - Volume.remove({ id: volume.Id }) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: data.message, err: data.message }); - } else { - deferred.resolve(); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to remove volume', err: err }); - }); - - return deferred.promise; - }; - - service.createVolumeConfiguration = function (name, driver, driverOptions) { - var volumeConfiguration = { - Name: name, - Driver: driver, - DriverOpts: VolumeHelper.createDriverOptions(driverOptions), - }; - return volumeConfiguration; - }; - - service.createVolume = function (volumeConfiguration) { - var deferred = $q.defer(); - Volume.create(volumeConfiguration) - .$promise.then(function success(data) { - if (data.message) { - deferred.reject({ msg: data.message }); - } else { - var volume = new VolumeViewModel(data); - deferred.resolve(volume); - } - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to create volume', err: err }); - }); - return deferred.promise; - }; - - service.createVolumes = function (volumeConfigurations) { - var createVolumeQueries = volumeConfigurations.map(function (volumeConfiguration) { - return service.createVolume(volumeConfiguration); - }); - return $q.all(createVolumeQueries); - }; - - service.createXAutoGeneratedLocalVolumes = function (x) { - var createVolumeQueries = []; - for (var i = 0; i < x; i++) { - createVolumeQueries.push(service.createVolume({ Driver: 'local' })); - } - return $q.all(createVolumeQueries); - }; - - return service; - }, -]); + /** + * @param {EnvironmentId} environmentId + * @param {number} count + */ + async function createXAutoGeneratedLocalVolumes(environmentId, count) { + const promises = Array(count).map(createVolume(environmentId, { Driver: 'local' })); + return Promise.all(promises); + } +} diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js index 43713e4ff..a57e05320 100644 --- a/app/docker/views/containers/console/containerConsoleController.js +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -9,28 +9,12 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [ 'ContainerService', 'ImageService', 'Notifications', - 'ContainerHelper', 'ExecService', 'HttpRequestHelper', - 'LocalStorage', 'CONSOLE_COMMANDS_LABEL_PREFIX', 'SidebarService', 'endpoint', - function ( - $scope, - $state, - $transition$, - ContainerService, - ImageService, - Notifications, - ContainerHelper, - ExecService, - HttpRequestHelper, - LocalStorage, - CONSOLE_COMMANDS_LABEL_PREFIX, - SidebarService, - endpoint - ) { + function ($scope, $state, $transition$, ContainerService, ImageService, Notifications, ExecService, HttpRequestHelper, CONSOLE_COMMANDS_LABEL_PREFIX, SidebarService, endpoint) { var socket, term; let states = Object.freeze({ @@ -97,7 +81,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [ $scope.state = states.connecting; var command = $scope.formValues.isCustomCommand ? $scope.formValues.customCommand : $scope.formValues.command; var execConfig = { - id: $transition$.params().id, AttachStdin: true, AttachStdout: true, AttachStderr: true, @@ -106,7 +89,7 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [ Cmd: commandStringToArray(command), }; - ContainerService.createExec(endpoint.Id, execConfig) + ContainerService.createExec(endpoint.Id, $transition$.params().id, execConfig) .then(function success(data) { const params = { endpointId: $state.params.endpointId, diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index 764e3a940..f7fa00bab 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -5,6 +5,7 @@ import { confirmContainerDeletion } from '@/react/docker/containers/common/confi import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { ResourceControlType } from '@/react/portainer/access-control/types'; import { confirmContainerRecreation } from '@/react/docker/containers/ItemView/ConfirmRecreationModal'; +import { commitContainer } from '@/react/docker/proxy/queries/useCommitContainerMutation'; angular.module('portainer.docker').controller('ContainerController', [ '$q', @@ -13,14 +14,13 @@ angular.module('portainer.docker').controller('ContainerController', [ '$transition$', '$filter', '$async', - 'Commit', 'ContainerService', 'ImageHelper', 'Notifications', 'HttpRequestHelper', 'Authentication', 'endpoint', - function ($q, $scope, $state, $transition$, $filter, $async, Commit, ContainerService, ImageHelper, Notifications, HttpRequestHelper, Authentication, endpoint) { + function ($q, $scope, $state, $transition$, $filter, $async, ContainerService, ImageHelper, Notifications, HttpRequestHelper, Authentication, endpoint) { $scope.resourceType = ResourceControlType.Container; $scope.endpoint = endpoint; $scope.isAdmin = Authentication.isAdmin(); @@ -204,7 +204,7 @@ angular.module('portainer.docker').controller('ContainerController', [ const registryModel = $scope.config.RegistryModel; const imageConfig = ImageHelper.createImageConfigForContainer(registryModel); try { - await Commit.commitContainer({ environmentId: endpoint.Id }, { id: $transition$.params().id, repo: imageConfig.fromImage }).$promise; + await commitContainer(endpoint.Id, { container: $transition$.params().id, repo: imageConfig.fromImage }); Notifications.success('Image created', $transition$.params().id); $state.reload(); } catch (err) { diff --git a/app/docker/views/events/eventsController.js b/app/docker/views/events/eventsController.js index 8affeaf84..3864eabc0 100644 --- a/app/docker/views/events/eventsController.js +++ b/app/docker/views/events/eventsController.js @@ -6,10 +6,10 @@ angular.module('portainer.docker').controller('EventsController', [ 'SystemService', function ($scope, Notifications, SystemService) { function initView() { - var from = moment().subtract(24, 'hour').unix(); - var to = moment().unix(); + const since = moment().subtract(24, 'hour').unix(); + const until = moment().unix(); - SystemService.events(from, to) + SystemService.events({ since, until }) .then(function success(data) { $scope.events = data; }) diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js index cd723236a..370d05512 100644 --- a/app/docker/views/images/imagesController.js +++ b/app/docker/views/images/imagesController.js @@ -35,15 +35,10 @@ angular.module('portainer.docker').controller('ImagesController', [ const registryModel = $scope.formValues.RegistryModel; var nodeName = $scope.formValues.NodeName; - HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); $scope.state.actionInProgress = true; - ImageService.pullImage(registryModel, false) - .then(function success(data) { - var err = data[data.length - 1].errorDetail; - if (err) { - return Notifications.error('Failure', err, 'Unable to pull image'); - } + ImageService.pullImage(registryModel, nodeName) + .then(function success() { Notifications.success('Image successfully pulled', registryModel.Image); $state.reload(); }) @@ -122,7 +117,7 @@ angular.module('portainer.docker').controller('ImagesController', [ $scope.state.exportInProgress = true; ImageService.downloadImages(images) .then(function success(data) { - var downloadData = new Blob([data.file], { type: 'application/x-tar' }); + var downloadData = new Blob([data], { type: 'application/x-tar' }); FileSaver.saveAs(downloadData, 'images.tar'); Notifications.success('Success', 'Image(s) successfully downloaded'); }) diff --git a/app/docker/views/networks/create/createNetworkController.js b/app/docker/views/networks/create/createNetworkController.js index 3b146970d..ae7749853 100644 --- a/app/docker/views/networks/create/createNetworkController.js +++ b/app/docker/views/networks/create/createNetworkController.js @@ -230,11 +230,8 @@ angular.module('portainer.docker').controller('CreateNetworkController', [ } function createNetwork(context) { - HttpRequestHelper.setPortainerAgentTargetHeader(context.nodeName); - HttpRequestHelper.setPortainerAgentManagerOperation(context.managerOperation); - $scope.state.actionInProgress = true; - NetworkService.create(context.networkConfiguration) + NetworkService.create(context.networkConfiguration, { nodeName: context.nodeName, agentManagerOperation: context.managerOperation }) .then(function success(data) { const userId = context.userDetails.ID; const accessControlData = context.accessControlData; diff --git a/app/docker/views/services/create/createServiceController.js b/app/docker/views/services/create/createServiceController.js index 1592f31b8..ea7ed4f08 100644 --- a/app/docker/views/services/create/createServiceController.js +++ b/app/docker/views/services/create/createServiceController.js @@ -14,7 +14,7 @@ angular.module('portainer.docker').controller('CreateServiceController', [ '$scope', '$state', '$timeout', - 'Service', + 'ServiceService', 'ServiceHelper', 'ConfigService', 'ConfigHelper', @@ -29,8 +29,6 @@ angular.module('portainer.docker').controller('CreateServiceController', [ 'Notifications', 'FormValidator', 'PluginService', - 'RegistryService', - 'HttpRequestHelper', 'NodeService', 'WebhookService', 'endpoint', @@ -39,7 +37,7 @@ angular.module('portainer.docker').controller('CreateServiceController', [ $scope, $state, $timeout, - Service, + ServiceService, ServiceHelper, ConfigService, ConfigHelper, @@ -54,8 +52,6 @@ angular.module('portainer.docker').controller('CreateServiceController', [ Notifications, FormValidator, PluginService, - RegistryService, - HttpRequestHelper, NodeService, WebhookService, endpoint @@ -523,11 +519,9 @@ angular.module('portainer.docker').controller('CreateServiceController', [ function createNewService(config, accessControlData) { const registryModel = $scope.formValues.RegistryModel; - var authenticationDetails = registryModel.Registry.Authentication ? RegistryService.encodedCredentials(registryModel.Registry) : ''; - HttpRequestHelper.setRegistryAuthenticationHeader(authenticationDetails); - Service.create(config) - .$promise.then(function success(data) { + ServiceService.create(config, registryModel.Registry.Authentication ? registryModel.Registry.Id : 0) + .then(function success(data) { const serviceId = data.ID; const resourceControl = data.Portainer.ResourceControl; const userId = Authentication.getUserDetails().ID; diff --git a/app/docker/views/volumes/create/createVolumeController.js b/app/docker/views/volumes/create/createVolumeController.js index f8fd902af..f37e30b56 100644 --- a/app/docker/views/volumes/create/createVolumeController.js +++ b/app/docker/views/volumes/create/createVolumeController.js @@ -3,7 +3,6 @@ import { VolumesNFSFormData } from '../../../components/volumesNFSForm/volumesNF import { VolumesCIFSFormData } from '../../../components/volumesCIFSForm/volumesCifsFormModel'; angular.module('portainer.docker').controller('CreateVolumeController', [ - '$q', '$scope', '$state', 'VolumeService', @@ -12,9 +11,8 @@ angular.module('portainer.docker').controller('CreateVolumeController', [ 'Authentication', 'Notifications', 'FormValidator', - 'HttpRequestHelper', 'endpoint', - function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator, HttpRequestHelper, endpoint) { + function ($scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator, endpoint) { $scope.endpoint = endpoint; $scope.formValues = { @@ -126,10 +124,9 @@ angular.module('portainer.docker').controller('CreateVolumeController', [ } var nodeName = $scope.formValues.NodeName; - HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); $scope.state.actionInProgress = true; - VolumeService.createVolume(volumeConfiguration) + VolumeService.createVolume(volumeConfiguration, nodeName) .then(function success(data) { const userId = userDetails.ID; const resourceControl = data.ResourceControl; diff --git a/app/docker/views/volumes/edit/volumeController.js b/app/docker/views/volumes/edit/volumeController.js index fa351b288..67eae5108 100644 --- a/app/docker/views/volumes/edit/volumeController.js +++ b/app/docker/views/volumes/edit/volumeController.js @@ -21,7 +21,7 @@ angular.module('portainer.docker').controller('VolumeController', [ $scope.removeVolume = function removeVolume() { confirmDelete('Do you want to remove this volume?').then((confirmed) => { if (confirmed) { - VolumeService.remove($scope.volume) + VolumeService.remove($scope.volume.Id) .then(function success() { Notifications.success('Volume successfully removed', $transition$.params().id); $state.go('docker.volumes', {}); diff --git a/app/docker/views/volumes/volumesController.js b/app/docker/views/volumes/volumesController.js index 6398459b1..5fa7547b4 100644 --- a/app/docker/views/volumes/volumesController.js +++ b/app/docker/views/volumes/volumesController.js @@ -10,15 +10,13 @@ angular.module('portainer.docker').controller('VolumesController', [ 'ServiceService', 'VolumeHelper', 'Notifications', - 'HttpRequestHelper', 'Authentication', 'endpoint', - function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, Authentication, endpoint) { + function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, Authentication, endpoint) { $scope.removeAction = function (selectedItems) { confirmDelete('Do you want to remove the selected volume(s)?').then(async (confirmed) => { async function doRemove(volume) { - HttpRequestHelper.setPortainerAgentTargetHeader(volume.NodeName); - return VolumeService.remove(volume) + return VolumeService.remove(volume.Id, volume.NodeName) .then(function success() { Notifications.success('Volume successfully removed', volume.Id); var index = $scope.volumes.indexOf(volume); @@ -42,8 +40,8 @@ angular.module('portainer.docker').controller('VolumesController', [ var endpointRole = $scope.applicationState.endpoint.mode.role; $q.all({ - attached: VolumeService.volumes({ filters: { dangling: ['false'] } }), - dangling: VolumeService.volumes({ filters: { dangling: ['true'] } }), + attached: VolumeService.volumes({ dangling: ['false'] }), + dangling: VolumeService.volumes({ dangling: ['true'] }), services: endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? ServiceService.services() : [], }) .then(function success(data) { diff --git a/app/global-axios.d.ts b/app/global-axios.d.ts new file mode 100644 index 000000000..63e5b1681 --- /dev/null +++ b/app/global-axios.d.ts @@ -0,0 +1,24 @@ +import { + MaxDockerAPIVersionKey, + MaxDockerAPIVersionType, +} from './portainer/services/dockerMaxApiVersion'; + +export * from 'axios'; + +declare module 'axios' { + interface CreateAxiosDefaults { + /** + * require to define a default max Docker API Version when creating an axios instance + */ + [MaxDockerAPIVersionKey]: MaxDockerAPIVersionType; + } + + interface AxiosRequestConfig { + /** + * represents the maximum Docker API version supported for the request + * + * the default will be used when not specified in the request config + */ + [MaxDockerAPIVersionKey]?: MaxDockerAPIVersionType; + } +} diff --git a/app/kubernetes/rest/pod.js b/app/kubernetes/rest/pod.js index 505558d41..dd97299a9 100644 --- a/app/kubernetes/rest/pod.js +++ b/app/kubernetes/rest/pod.js @@ -1,5 +1,4 @@ import { rawResponse } from 'Kubernetes/rest/response/transform'; -import { logsHandler } from 'Docker/rest/response/handlers'; angular.module('portainer.kubernetes').factory('KubernetesPods', [ '$resource', @@ -48,3 +47,11 @@ angular.module('portainer.kubernetes').factory('KubernetesPods', [ }; }, ]); + +// The Docker API returns the logs as a single string. +// This handler wraps the data in a JSON object under the "logs" property. +function logsHandler(data) { + return { + logs: data, + }; +} diff --git a/app/portainer/filters/index.js b/app/portainer/filters/index.js index 0e817d29f..c4b314cc7 100644 --- a/app/portainer/filters/index.js +++ b/app/portainer/filters/index.js @@ -1,7 +1,7 @@ import angular from 'angular'; import _ from 'lodash-es'; -import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { ownershipIcon } from '@/react/docker/components/datatables/createOwnershipColumn'; import { arrayToStr, environmentTypeIcon, diff --git a/app/portainer/services/angularToReact.ts b/app/portainer/services/angularToReact.ts new file mode 100644 index 000000000..b6b08095b --- /dev/null +++ b/app/portainer/services/angularToReact.ts @@ -0,0 +1,116 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { EndpointProviderInterface } from './endpointProvider'; + +// see async.js +type AsyncInterface = ( + asyncFunc: AsyncFunction, + ...args: unknown[] +) => Promise; + +type AsyncFunction = (...params: unknown[]) => Promise; +type AxiosFunction = ( + environmentId: EnvironmentId | undefined, + ...params: unknown[] +) => Promise; + +/* @ngInject */ +export function AngularToReact( + EndpointProvider: EndpointProviderInterface, + $async: AsyncInterface +) { + return { useAxios, injectEnvironmentId }; + + /** + * Wraps the async axios function with `$async` to ensures the request runs inside the AngularJS digest cycle + * + * See `$async` (async.js) implementation and notes + * + * See `AngularToReact.injectEnvironmentId` to solve `environmentId` injection for services functions relying + * on `EndpointProvider.endpointID()` in their `$resource()` definition + * + * @example + * **Old AngularJS service** + * ``` + * // file:: AngularJS service.js + * + * // ngInject + * function ServiceServiceFactory($q, Service) { + * return { getService }; + * + * // the original signature doesn't have environmentId passed to it + * // it relies on EndpointProvider in $resource() definition + * // we will inject it on refactor + * // the function uses $q, which internally triggers a redraw of the UI when it resolves/rejects + * function getService(serviceId) { + * var deferred = $q.defer(); + * [...] + * return deferred.promise; + * }; + * + * // the original signature has environmentId passed to it + * // it doesn't rely on EndpointProvider in $resource() definition + * // we won't inject environmentId on refactor + * // the function uses $q, which internally triggers a redraw of the UI when it resolves/rejects + * function listServices(environmentId) { + * var deferred = $q.defer(); + * [...] + * return deferred.promise; + * }; + * } + * ``` + * + * **New format** + * ``` + * // file:: '@/react/.../useService.ts' + * // this function has `environmentId` as first parameter, which doesn't match the old AngularJS service signature + * export async function getService(environmentId: EnvironmentId, serviceId: ServiceId) { + * // axios.get() + * } + * // file:: '@/react/.../useServices.ts' + * // this function has `environmentId` as first parameter, which matches the old AngularJS service signature + * export async function listServices(environmentId: EnvironmentId, serviceId: ServiceId) { + * // axios.get() + * } + * // file:: AngularJS service.js + * import { getService } from '@/react/.../useService.ts'; + * import { listServices } from '@/react/.../useServices.ts'; + * + * // ngInject + * function ServiceServiceFactory(AngularToReact) { + * const { useAxios, injectEnvironmentId } = AngularToReact; + * return { + * // ask to inject environmentId to maintain the old signature + * getService: useAxios(injectEnvironmentId(getService)), + * // do not ask to inject environmentId as it was already in the old signature + * // and is already passed by the caller + * listServices: useAxios(listServices), + * }; + * } + * ``` + */ + function useAxios(axiosFunc: AxiosFunction) { + return (...params: unknown[]) => + $async(axiosFunc as AsyncFunction, ...params); + } + + /** + * Wraps the Axios function taking `endpointId` as first param to expose the old service format. + * + * Leverage injected `EndpointProvider` that was used in the rest file - `$resource()` definition. + * + * The axios function params **MUST** match the old AngularJS-service ones to use this helper without changing the service calls + * + * Should be used in conjunction with `AngularToReact.useAxios` + * + * @example + * See `AngularToReact.useAxios` + * + * @param {(environmentId: EnvironmentId, ...params: unknown[]) => Promise} axiosFunc Axios function taking `environmentId` as first param + * @returns a function with the old AngularJS signature + */ + function injectEnvironmentId(axiosFunc: AxiosFunction) { + return async (...params: unknown[]) => + axiosFunc(EndpointProvider.endpointID(), ...params); + } +} diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index 95af739f9..abeb09165 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -54,11 +54,11 @@ angular.module('portainer.app').factory('StackService', [ SwarmService.swarm(targetEndpointId) .then(function success(data) { var swarm = data; - if (swarm.Id === stack.SwarmId) { + if (swarm.ID === stack.SwarmId) { deferred.reject({ msg: 'Target environment is located in the same Swarm cluster as the current environment', err: null }); return; } - return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.Id, Name: newName }).$promise; + return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.ID, Name: newName }).$promise; }) .then(function success() { deferred.resolve(); @@ -182,10 +182,10 @@ angular.module('portainer.app').factory('StackService', [ service.swarmStacks = function (endpointId, includeExternalStacks, filters = {}) { var deferred = $q.defer(); - SwarmService.swarm() + SwarmService.swarm(endpointId) .then(function success(data) { var swarm = data; - filters = { SwarmID: swarm.Id, ...filters }; + filters = { SwarmID: swarm.ID, ...filters }; return $q.all({ stacks: Stack.query({ filters: filters }).$promise, @@ -239,10 +239,10 @@ angular.module('portainer.app').factory('StackService', [ var deferred = $q.defer(); if (stack.Type == 1) { - SwarmService.swarm() + SwarmService.swarm(endpointId) .then(function success(data) { const swarm = data; - return Stack.associate({ id: stack.Id, endpointId: endpointId, swarmId: swarm.Id, orphanedRunning }).$promise; + return Stack.associate({ id: stack.Id, endpointId: endpointId, swarmId: swarm.ID, orphanedRunning }).$promise; }) .then(function success(data) { deferred.resolve(data); @@ -305,10 +305,10 @@ angular.module('portainer.app').factory('StackService', [ service.createSwarmStackFromFileUpload = function (name, stackFile, env, endpointId) { var deferred = $q.defer(); - SwarmService.swarm() + SwarmService.swarm(endpointId) .then(function success(data) { var swarm = data; - return FileUploadService.createSwarmStack(name, swarm.Id, stackFile, env, endpointId); + return FileUploadService.createSwarmStack(name, swarm.ID, stackFile, env, endpointId); }) .then(function success(data) { deferred.resolve(data.data); @@ -335,7 +335,7 @@ angular.module('portainer.app').factory('StackService', [ .then(function success(swarm) { var payload = { Name: name, - SwarmID: swarm.Id, + SwarmID: swarm.ID, StackFileContent: stackFileContent, Env: env, }; @@ -376,12 +376,12 @@ angular.module('portainer.app').factory('StackService', [ service.createSwarmStackFromGitRepository = function (name, repositoryOptions, env, endpointId) { var deferred = $q.defer(); - SwarmService.swarm() + SwarmService.swarm(endpointId) .then(function success(data) { var swarm = data; var payload = { Name: name, - SwarmID: swarm.Id, + SwarmID: swarm.ID, RepositoryURL: repositoryOptions.RepositoryURL, RepositoryReferenceName: repositoryOptions.RepositoryReferenceName, ComposeFile: repositoryOptions.ComposeFilePathInRepository, diff --git a/app/portainer/services/axios.ts b/app/portainer/services/axios.ts index 797e3f346..7f7683cd1 100644 --- a/app/portainer/services/axios.ts +++ b/app/portainer/services/axios.ts @@ -21,6 +21,8 @@ import { portainerAgentManagerOperation, portainerAgentTargetHeader, } from './http-request.helper'; +import { dockerMaxAPIVersionInterceptor } from './dockerMaxApiVersionInterceptor'; +import { MAX_DOCKER_API_VERSION } from './dockerMaxApiVersion'; const portainerCacheHeader = 'X-Portainer-Cache'; @@ -48,7 +50,10 @@ function headerInterpreter( return 'not enough headers'; } -const axios = Axios.create({ baseURL: 'api' }); +const axios = Axios.create({ + baseURL: 'api', + maxDockerAPIVersion: MAX_DOCKER_API_VERSION, +}); axios.interceptors.request.use((req) => { dispatchCacheRefreshEventIfNeeded(req); return req; @@ -118,13 +123,14 @@ export function agentInterceptor(config: InternalAxiosRequestConfig) { return newConfig; } +axios.interceptors.request.use(dockerMaxAPIVersionInterceptor); axios.interceptors.request.use(agentInterceptor); axios.interceptors.response.use(undefined, (error) => { if ( error.response?.status === 401 && - !error.config.url.includes('/v2/') && - !error.config.url.includes('/api/v4/') && + !error.config.url.includes('/v2/') && // docker proxy through agent + !error.config.url.includes('/api/v4/') && // gitlab proxy isTransitionRequiresAuthentication() ) { // eslint-disable-next-line no-console @@ -188,6 +194,12 @@ export function defaultErrorParser(axiosError: AxiosError) { const error = new Error(message); return { error, details }; } + if (isArrayResponse(axiosError.response?.data)) { + const message = axiosError.response?.data[0].message || ''; + const details = axiosError.response?.data[0].details || message; + const error = new Error(message); + return { error, details }; + } const details = axiosError.response?.data ? axiosError.response?.data.toString() @@ -196,6 +208,16 @@ export function defaultErrorParser(axiosError: AxiosError) { return { error, details }; } +// handle jsonObjectsToArrayHandler transformation +function isArrayResponse(data: unknown): data is DefaultAxiosErrorType[] { + return ( + !!data && + Array.isArray(data) && + 'message' in data[0] && + typeof data[0].message === 'string' + ); +} + export function isDefaultResponse( data: unknown ): data is DefaultAxiosErrorType { @@ -249,3 +271,18 @@ export function json2formData(json: Record) { return formData; } + +/** + * The Docker API often returns a list of JSON object. + * This handler wrap the JSON objects in an array. + * @param data Raw docker API response (stream of objects in a single string) + * @returns An array of parsed objects + */ +export function jsonObjectsToArrayHandler(data: string): unknown[] { + // catching empty data helps the function not to fail and prevents unwanted error message to user. + if (!data) { + return []; + } + const str = `[${data.replace(/\n/g, ' ').replace(/\}\s*\{/g, '}, {')}]`; + return JSON.parse(str); +} diff --git a/app/portainer/services/dockerMaxApiVersion.ts b/app/portainer/services/dockerMaxApiVersion.ts new file mode 100644 index 000000000..c1c701671 --- /dev/null +++ b/app/portainer/services/dockerMaxApiVersion.ts @@ -0,0 +1,11 @@ +// Key used in axios types definitions +export const MaxDockerAPIVersionKey = 'maxDockerAPIVersion' as const; + +export type DockerAPIVersionType = number; + +// this is the version we are using with the generated API types +export const MAX_DOCKER_API_VERSION: DockerAPIVersionType = 1.41; + +// https://docs.docker.com/engine/api/#api-version-matrix +// Docker 26 = API 1.45 +export const LATEST_DOCKER_API_VERSION: DockerAPIVersionType = 1.45; diff --git a/app/portainer/services/dockerMaxApiVersionInterceptor.ts b/app/portainer/services/dockerMaxApiVersionInterceptor.ts new file mode 100644 index 000000000..267efd98f --- /dev/null +++ b/app/portainer/services/dockerMaxApiVersionInterceptor.ts @@ -0,0 +1,64 @@ +import { SystemVersion } from 'docker-types/generated/1.41'; +import Axios, { InternalAxiosRequestConfig } from 'axios'; +import { setupCache, buildMemoryStorage } from 'axios-cache-interceptor'; + +import { buildDockerProxyUrl } from '@/react/docker/proxy/queries/buildDockerProxyUrl'; + +import PortainerError from '../error'; + +import { MAX_DOCKER_API_VERSION } from './dockerMaxApiVersion'; + +const envVersionAxios = Axios.create({ + baseURL: 'api', + maxDockerAPIVersion: MAX_DOCKER_API_VERSION, +}); + +// setup a cache for the intermediary request sent by the interceptor +const envVersionCache = buildMemoryStorage(); +setupCache(envVersionAxios, { + storage: envVersionCache, + ttl: 5 * 60 * 1000, + methods: ['get'], +}); + +export async function dockerMaxAPIVersionInterceptor( + rawConfig: InternalAxiosRequestConfig +) { + try { + const config = rawConfig; + const found = config.url?.match( + /endpoints\/(?\d+)\/docker\// + ); + + if (found && found.groups) { + const { environmentId } = found.groups; + const envId = parseInt(environmentId, 10); + + // if we cannot parse the env ID, don't send a request that will fail, + // exit the interceptor and let the original request config pass through + if (Number.isNaN(envId)) { + return config; + } + + const { data } = await envVersionAxios.get( + buildDockerProxyUrl(envId, 'version') + ); + + const apiVersion = parseFloat(data.ApiVersion ?? '0'); + const { maxDockerAPIVersion } = config; + + if (apiVersion > maxDockerAPIVersion) { + config.url = config.url?.replace( + /docker/, + `docker/v${maxDockerAPIVersion}` + ); + } + } + return config; + } catch (err) { + throw new PortainerError( + 'An error occurred while trying to limit request to the maximum supported Docker API version', + err + ); + } +} diff --git a/app/portainer/services/endpointProvider.ts b/app/portainer/services/endpointProvider.ts index 4f7fcbf58..34d661887 100644 --- a/app/portainer/services/endpointProvider.ts +++ b/app/portainer/services/endpointProvider.ts @@ -1,4 +1,4 @@ -import { ping } from '@/docker/services/ping'; +import { ping } from '@/react/docker/proxy/queries/usePing'; import { environmentStore } from '@/react/hooks/current-environment-store'; import { Environment, diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index bc9664c3f..eb639d084 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -1,238 +1,224 @@ import { PortainerEndpointCreationTypes } from 'Portainer/models/endpoint/models'; -import { genericHandler, jsonObjectsToArrayHandler } from '../../docker/rest/response/handlers'; -angular.module('portainer.app').factory('FileUploadService', [ - '$q', - 'Upload', - 'EndpointProvider', - function FileUploadFactory($q, Upload, EndpointProvider) { - 'use strict'; +angular.module('portainer.app').factory('FileUploadService', FileUploadFactory); - var service = {}; +/* @ngInject */ +function FileUploadFactory($q, Upload) { + var service = { + // createSchedule, // edge jobs service + // uploadBackup, // backup service + // createSwarmStack, // stack service + // createComposeStack, // stack service + // createEdgeStack, // edge stack service + // createCustomTemplate, // custom template service + // configureRegistry, // registry service + // createEndpoint, // endpoint service + // createAzureEndpoint, // endpoint service + // createKubeConfigEndpoint, // endpoint service + // uploadLDAPTLSFiles, // auth settings controller + // uploadTLSFilesForEndpoint, // endpoint service + // uploadOwnershipVoucher, // import device controller + }; - function uploadFile(url, file) { - return Upload.upload({ url: url, data: { file: file } }); + function uploadFile(url, file) { + return Upload.upload({ url: url, data: { file: file } }); + } + + service.createSchedule = function (payload) { + return Upload.upload({ + url: 'api/edge_jobs/create/file', + data: { + file: payload.File, + Name: payload.Name, + CronExpression: payload.CronExpression, + Image: payload.Image, + Endpoints: Upload.json(payload.Endpoints), + RetryCount: payload.RetryCount, + RetryInterval: payload.RetryInterval, + }, + }); + }; + + service.uploadBackup = function (file, password) { + return Upload.upload({ + url: 'api/restore', + data: { + file, + password, + }, + }); + }; + + service.createSwarmStack = function (stackName, swarmId, file, env, endpointId, webhook) { + return Upload.upload({ + url: `api/stacks/create/swarm/file?endpointId=${endpointId}`, + data: { + file: file, + Name: stackName, + SwarmID: swarmId, + Env: Upload.json(env), + Webhook: webhook, + }, + ignoreLoadingBar: true, + }); + }; + + service.createComposeStack = function (stackName, file, env, endpointId, webhook) { + return Upload.upload({ + url: `api/stacks/create/standalone/file?endpointId=${endpointId}`, + data: { + file: file, + Name: stackName, + Env: Upload.json(env), + Webhook: webhook, + }, + ignoreLoadingBar: true, + }); + }; + + service.createEdgeStack = function createEdgeStack({ EdgeGroups, Registries, envVars, staggerConfig, ...payload }, file, dryrun) { + return Upload.upload({ + url: `api/edge_stacks/create/file?dryrun=${dryrun}`, + data: { + file, + EdgeGroups: Upload.json(EdgeGroups), + Registries: Upload.json(Registries), + EnvVars: Upload.json(envVars), + StaggerConfig: Upload.json(staggerConfig), + ...payload, + }, + ignoreLoadingBar: true, + }); + }; + + service.createCustomTemplate = function createCustomTemplate(data) { + return Upload.upload({ + url: 'api/custom_templates/create/file', + data, + ignoreLoadingBar: true, + }); + }; + + service.configureRegistry = function (registryId, registryManagementConfigurationModel) { + return Upload.upload({ + url: 'api/registries/' + registryId + '/configure', + data: registryManagementConfigurationModel, + }); + }; + + service.createEndpoint = function ( + name, + creationType, + URL, + PublicURL, + groupID, + tagIds, + TLS, + TLSSkipVerify, + TLSSkipClientVerify, + TLSCAFile, + TLSCertFile, + TLSKeyFile, + checkinInterval, + EdgePingInterval, + EdgeSnapshotInterval, + EdgeCommandInterval + ) { + return Upload.upload({ + url: 'api/endpoints', + data: { + Name: name, + EndpointCreationType: creationType, + URL: URL, + PublicURL: PublicURL, + GroupID: groupID, + TagIds: Upload.json(tagIds), + TLS: TLS, + TLSSkipVerify: TLSSkipVerify, + TLSSkipClientVerify: TLSSkipClientVerify, + TLSCACertFile: TLSCAFile, + TLSCertFile: TLSCertFile, + TLSKeyFile: TLSKeyFile, + CheckinInterval: checkinInterval, + EdgePingInterval: EdgePingInterval, + EdgeSnapshotInterval: EdgeSnapshotInterval, + EdgeCommandInterval: EdgeCommandInterval, + }, + ignoreLoadingBar: true, + }); + }; + + service.createAzureEndpoint = function (name, applicationId, tenantId, authenticationKey, groupId, tagIds) { + return Upload.upload({ + url: 'api/endpoints', + data: { + Name: name, + EndpointCreationType: PortainerEndpointCreationTypes.AzureEnvironment, + GroupID: groupId, + TagIds: Upload.json(tagIds), + AzureApplicationID: applicationId, + AzureTenantID: tenantId, + AzureAuthenticationKey: authenticationKey, + }, + ignoreLoadingBar: true, + }); + }; + + service.createKubeConfigEndpoint = function (name, kubeConfig, groupId, tagIds) { + return Upload.upload({ + url: 'api/endpoints', + data: { + Name: name, + EndpointCreationType: PortainerEndpointCreationTypes.KubeConfigEnvironment, + GroupID: groupId, + TagIds: Upload.json(tagIds), + KubeConfig: kubeConfig, + }, + ignoreLoadingBar: true, + }); + }; + + service.uploadLDAPTLSFiles = function (TLSCAFile, TLSCertFile, TLSKeyFile) { + var queue = []; + + if (TLSCAFile) { + queue.push(uploadFile('api/upload/tls/ca?folder=ldap', TLSCAFile)); + } + if (TLSCertFile) { + queue.push(uploadFile('api/upload/tls/cert?folder=ldap', TLSCertFile)); + } + if (TLSKeyFile) { + queue.push(uploadFile('api/upload/tls/key?folder=ldap', TLSKeyFile)); } - service.buildImage = function (endpointID, names, file, path) { - return Upload.http({ - url: `api/endpoints/${endpointID}/docker/build`, - headers: { - 'Content-Type': file.type, - }, - data: file, - params: { - t: names, - dockerfile: path, - }, - ignoreLoadingBar: true, - transformResponse: function (data) { - return jsonObjectsToArrayHandler(data); - }, - }); - }; + return $q.all(queue); + }; - service.buildImageFromFiles = function (endpointID, names, files) { - return Upload.upload({ - url: `api/endpoints/${endpointID}/docker/build`, - headers: { - 'Content-Type': 'multipart/form-data', - }, - data: { file: files }, - params: { - t: names, - }, - transformResponse: function (data) { - return jsonObjectsToArrayHandler(data); - }, - }); - }; + service.uploadTLSFilesForEndpoint = function (endpointID, TLSCAFile, TLSCertFile, TLSKeyFile) { + var queue = []; - service.loadImages = function (file) { - var endpointID = EndpointProvider.endpointID(); - return Upload.http({ - url: 'api/endpoints/' + endpointID + '/docker/images/load', - headers: { - 'Content-Type': file.type, - }, - data: file, - ignoreLoadingBar: true, - transformResponse: genericHandler, - }); - }; + if (TLSCAFile) { + queue.push(uploadFile('api/upload/tls/ca?folder=' + endpointID, TLSCAFile)); + } + if (TLSCertFile) { + queue.push(uploadFile('api/upload/tls/cert?folder=' + endpointID, TLSCertFile)); + } + if (TLSKeyFile) { + queue.push(uploadFile('api/upload/tls/key?folder=' + endpointID, TLSKeyFile)); + } - service.createSchedule = function (payload) { - return Upload.upload({ - url: 'api/edge_jobs/create/file', - data: { - file: payload.File, - Name: payload.Name, - CronExpression: payload.CronExpression, - Image: payload.Image, - Endpoints: Upload.json(payload.Endpoints), - RetryCount: payload.RetryCount, - RetryInterval: payload.RetryInterval, - }, - }); - }; + return $q.all(queue); + }; - service.uploadBackup = function (file, password) { - return Upload.upload({ - url: 'api/restore', - data: { - file, - password, - }, - }); - }; + service.uploadOwnershipVoucher = function (voucherFile) { + return Upload.upload({ + url: 'api/fdo/register', + data: { + voucher: voucherFile, + }, + ignoreLoadingBar: true, + }); + }; - service.createSwarmStack = function (stackName, swarmId, file, env, endpointId) { - return Upload.upload({ - url: `api/stacks/create/swarm/file?endpointId=${endpointId}`, - data: { - file: file, - Name: stackName, - SwarmID: swarmId, - Env: Upload.json(env), - }, - ignoreLoadingBar: true, - }); - }; - - service.createComposeStack = function (stackName, file, env, endpointId) { - return Upload.upload({ - url: `api/stacks/create/standalone/file?endpointId=${endpointId}`, - data: { - file: file, - Name: stackName, - Env: Upload.json(env), - }, - ignoreLoadingBar: true, - }); - }; - - service.createEdgeStack = function createEdgeStack({ EdgeGroups, envVars, ...payload }, file) { - return Upload.upload({ - url: `api/edge_stacks/create/file`, - data: { - file, - EdgeGroups: Upload.json(EdgeGroups), - EnvVars: Upload.json(envVars), - ...payload, - }, - ignoreLoadingBar: true, - }); - }; - - service.createCustomTemplate = function createCustomTemplate(data) { - return Upload.upload({ - url: 'api/custom_templates/create/file', - data, - ignoreLoadingBar: true, - }); - }; - - service.configureRegistry = function (registryId, registryManagementConfigurationModel) { - return Upload.upload({ - url: 'api/registries/' + registryId + '/configure', - data: registryManagementConfigurationModel, - }); - }; - - service.createEndpoint = function ( - name, - creationType, - URL, - PublicURL, - groupID, - tagIds, - TLS, - TLSSkipVerify, - TLSSkipClientVerify, - TLSCAFile, - TLSCertFile, - TLSKeyFile, - checkinInterval - ) { - return Upload.upload({ - url: 'api/endpoints', - data: { - Name: name, - EndpointCreationType: creationType, - URL: URL, - PublicURL: PublicURL, - GroupID: groupID, - TagIds: Upload.json(tagIds), - TLS: TLS, - TLSSkipVerify: TLSSkipVerify, - TLSSkipClientVerify: TLSSkipClientVerify, - TLSCACertFile: TLSCAFile, - TLSCertFile: TLSCertFile, - TLSKeyFile: TLSKeyFile, - CheckinInterval: checkinInterval, - }, - ignoreLoadingBar: true, - }); - }; - - service.createAzureEndpoint = function (name, applicationId, tenantId, authenticationKey, groupId, tagIds) { - return Upload.upload({ - url: 'api/endpoints', - data: { - Name: name, - EndpointCreationType: PortainerEndpointCreationTypes.AzureEnvironment, - GroupID: groupId, - TagIds: Upload.json(tagIds), - AzureApplicationID: applicationId, - AzureTenantID: tenantId, - AzureAuthenticationKey: authenticationKey, - }, - ignoreLoadingBar: true, - }); - }; - - service.uploadLDAPTLSFiles = function (TLSCAFile, TLSCertFile, TLSKeyFile) { - var queue = []; - - if (TLSCAFile) { - queue.push(uploadFile('api/upload/tls/ca?folder=ldap', TLSCAFile)); - } - if (TLSCertFile) { - queue.push(uploadFile('api/upload/tls/cert?folder=ldap', TLSCertFile)); - } - if (TLSKeyFile) { - queue.push(uploadFile('api/upload/tls/key?folder=ldap', TLSKeyFile)); - } - - return $q.all(queue); - }; - - service.uploadTLSFilesForEndpoint = function (endpointID, TLSCAFile, TLSCertFile, TLSKeyFile) { - var queue = []; - - if (TLSCAFile) { - queue.push(uploadFile('api/upload/tls/ca?folder=' + endpointID, TLSCAFile)); - } - if (TLSCertFile) { - queue.push(uploadFile('api/upload/tls/cert?folder=' + endpointID, TLSCertFile)); - } - if (TLSKeyFile) { - queue.push(uploadFile('api/upload/tls/key?folder=' + endpointID, TLSKeyFile)); - } - - return $q.all(queue); - }; - - service.uploadOwnershipVoucher = function (voucherFile) { - return Upload.upload({ - url: 'api/fdo/register', - data: { - voucher: voucherFile, - }, - ignoreLoadingBar: true, - }); - }; - - return service; - }, -]); + return service; +} diff --git a/app/portainer/services/index.ts b/app/portainer/services/index.ts index d4438c485..1d0509222 100644 --- a/app/portainer/services/index.ts +++ b/app/portainer/services/index.ts @@ -4,9 +4,11 @@ import { apiServicesModule } from './api'; import { Notifications } from './notifications'; import { HttpRequestHelperAngular } from './http-request.helper'; import { EndpointProvider } from './endpointProvider'; +import { AngularToReact } from './angularToReact'; export default angular .module('portainer.app.services', [apiServicesModule]) .factory('Notifications', Notifications) .factory('EndpointProvider', EndpointProvider) - .factory('HttpRequestHelper', HttpRequestHelperAngular).name; + .factory('HttpRequestHelper', HttpRequestHelperAngular) + .factory('AngularToReact', AngularToReact).name; diff --git a/app/portainer/views/templates/templatesController.js b/app/portainer/views/templates/templatesController.js index 86131b458..97658cb81 100644 --- a/app/portainer/views/templates/templatesController.js +++ b/app/portainer/views/templates/templatesController.js @@ -117,7 +117,7 @@ angular.module('portainer.app').controller('TemplatesController', [ return ImageService.pullImage(template.RegistryModel, true); }) .then(function success() { - return ContainerService.createAndStartContainer(endpoint.Id, templateConfiguration); + return ContainerService.createAndStartContainer(endpoint, templateConfiguration); }) .then(function success(data) { const resourceControl = data.Portainer.ResourceControl; diff --git a/app/react/azure/container-instances/ListView/columns/ownership.tsx b/app/react/azure/container-instances/ListView/columns/ownership.tsx index 29843b32c..0e3a2e7ba 100644 --- a/app/react/azure/container-instances/ListView/columns/ownership.tsx +++ b/app/react/azure/container-instances/ListView/columns/ownership.tsx @@ -4,7 +4,7 @@ import { CellContext } from '@tanstack/react-table'; import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; import { ContainerGroup } from '@/react/azure/types'; import { determineOwnership } from '@/react/portainer/access-control/models/ResourceControlViewModel'; -import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { ownershipIcon } from '@/react/docker/components/datatables/createOwnershipColumn'; import { columnHelper } from './helper'; diff --git a/app/react/azure/types.ts b/app/react/azure/types.ts index 3f1a1d781..38dae3436 100644 --- a/app/react/azure/types.ts +++ b/app/react/azure/types.ts @@ -1,5 +1,5 @@ import { AccessControlFormData } from '@/react/portainer/access-control/types'; -import { PortainerMetadata } from '@/react/docker/types'; +import { PortainerResponse } from '@/react/docker/types'; import { PortMapping } from './container-instances/CreateView/PortsMappingField'; @@ -47,14 +47,13 @@ interface ContainerGroupProperties { osType: OS; } -export type ContainerGroup = { +export type ContainerGroup = PortainerResponse<{ id: string; name: string; location: string; type: string; properties: ContainerGroupProperties; - Portainer?: PortainerMetadata; -}; +}>; export interface Subscription { subscriptionId: string; diff --git a/app/react/common/stacks/ItemView/StackContainersDatatable.tsx b/app/react/common/stacks/ItemView/StackContainersDatatable.tsx index 3d686b3f6..ca9a6d1ea 100644 --- a/app/react/common/stacks/ItemView/StackContainersDatatable.tsx +++ b/app/react/common/stacks/ItemView/StackContainersDatatable.tsx @@ -1,6 +1,6 @@ import { Box } from 'lucide-react'; -import { DockerContainer } from '@/react/docker/containers/types'; +import { ContainerListViewModel } from '@/react/docker/containers/types'; import { Environment } from '@/react/portainer/environments/types'; import { createStore } from '@/react/docker/containers/ListView/ContainersDatatable/datatable-store'; import { useColumns } from '@/react/docker/containers/ListView/ContainersDatatable/columns'; @@ -20,7 +20,7 @@ import { import { TableSettingsProvider } from '@@/datatables/useTableSettings'; import { useTableState } from '@@/datatables/useTableState'; -import { useContainers } from '../../../docker/containers/queries/containers'; +import { useContainers } from '../../../docker/containers/queries/useContainers'; import { RowProvider } from '../../../docker/containers/ListView/ContainersDatatable/RowContext'; const storageKey = 'stack-containers'; @@ -70,7 +70,7 @@ export function StackContainersDatatable({ environment, stackName }: Props) { initialTableState={getColumnVisibilityState(tableState.hiddenColumns)} renderTableSettings={(tableInstance) => ( <> - + table={tableInstance} onChange={(hiddenColumns) => { tableState.setHiddenColumns(hiddenColumns); diff --git a/app/react/docker/DashboardView/ContainerStatus.tsx b/app/react/docker/DashboardView/ContainerStatus.tsx index d6e552459..afff9cbe8 100644 --- a/app/react/docker/DashboardView/ContainerStatus.tsx +++ b/app/react/docker/DashboardView/ContainerStatus.tsx @@ -3,15 +3,17 @@ import { Heart, Power } from 'lucide-react'; import { Icon } from '@/react/components/Icon'; import { - DockerContainer, + ContainerListViewModel, ContainerStatus as Status, } from '../containers/types'; interface Props { - containers: DockerContainer[]; + containers: ContainerListViewModel[]; } -export function useContainerStatusComponent(containers: DockerContainer[]) { +export function useContainerStatusComponent( + containers: ContainerListViewModel[] +) { return ; } @@ -42,23 +44,23 @@ export function ContainerStatus({ containers }: Props) { ); } -function runningContainersFilter(containers: DockerContainer[]) { +function runningContainersFilter(containers: ContainerListViewModel[]) { return containers.filter( (container) => container.Status === Status.Running || container.Status === Status.Healthy ).length; } -function stoppedContainersFilter(containers: DockerContainer[]) { +function stoppedContainersFilter(containers: ContainerListViewModel[]) { return containers.filter( (container) => container.Status === Status.Exited || container.Status === Status.Stopped ).length; } -function healthyContainersFilter(containers: DockerContainer[]) { +function healthyContainersFilter(containers: ContainerListViewModel[]) { return containers.filter((container) => container.Status === Status.Healthy) .length; } -function unhealthyContainersFilter(containers: DockerContainer[]) { +function unhealthyContainersFilter(containers: ContainerListViewModel[]) { return containers.filter((container) => container.Status === Status.Unhealthy) .length; } diff --git a/app/react/docker/DashboardView/useDashboard.ts b/app/react/docker/DashboardView/useDashboard.ts new file mode 100644 index 000000000..4d4f3c02b --- /dev/null +++ b/app/react/docker/DashboardView/useDashboard.ts @@ -0,0 +1,41 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys } from '../queries/utils'; +import { buildDockerUrl } from '../queries/utils/buildDockerUrl'; + +interface DashboardResponse { + containers: { + total: number; + running: number; + stopped: number; + healthy: number; + unhealthy: number; + }; + services: number; + images: { + total: number; + size: number; + }; + volumes: number; + networks: number; + stacks: number; +} + +export function useDashboard(envId: EnvironmentId) { + return useQuery({ + queryFn: async () => { + try { + const res = await axios.get( + buildDockerUrl(envId, 'dashboard') + ); + return res.data; + } catch (error) { + throw parseAxiosError(error); + } + }, + queryKey: [...queryKeys.root(envId), 'dashboard'] as const, + }); +} diff --git a/app/react/docker/agent/queries/useApiVersion.ts b/app/react/docker/agent/queries/useApiVersion.ts index 1154d82f7..509bedb20 100644 --- a/app/react/docker/agent/queries/useApiVersion.ts +++ b/app/react/docker/agent/queries/useApiVersion.ts @@ -6,7 +6,7 @@ import axios, { } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { buildUrl } from '../../proxy/queries/build-url'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; export function useApiVersion(environmentId: EnvironmentId) { return useQuery(['environment', environmentId, 'agent', 'ping'], () => @@ -16,7 +16,9 @@ export function useApiVersion(environmentId: EnvironmentId) { async function getApiVersion(environmentId: EnvironmentId) { try { - const { headers } = await axios.get(buildUrl(environmentId, 'ping')); + const { headers } = await axios.get( + buildDockerProxyUrl(environmentId, 'ping') + ); return parseInt(headers['portainer-agent-api-version'], 10) || 1; } catch (error) { // 404 - agent is up - set version to 1 diff --git a/app/react/docker/components/datatable/TableColumnHeaderImageUpToDate.tsx b/app/react/docker/components/datatables/TableColumnHeaderImageUpToDate.tsx similarity index 100% rename from app/react/docker/components/datatable/TableColumnHeaderImageUpToDate.tsx rename to app/react/docker/components/datatables/TableColumnHeaderImageUpToDate.tsx diff --git a/app/react/docker/components/datatable/createOwnershipColumn.tsx b/app/react/docker/components/datatables/createOwnershipColumn.tsx similarity index 100% rename from app/react/docker/components/datatable/createOwnershipColumn.tsx rename to app/react/docker/components/datatables/createOwnershipColumn.tsx diff --git a/app/react/docker/configs/ListView/ConfigsDatatable/columns.tsx b/app/react/docker/configs/ListView/ConfigsDatatable/columns.tsx index 63c086d8f..5c47f55c4 100644 --- a/app/react/docker/configs/ListView/ConfigsDatatable/columns.tsx +++ b/app/react/docker/configs/ListView/ConfigsDatatable/columns.tsx @@ -1,7 +1,7 @@ import { createColumnHelper } from '@tanstack/react-table'; import { isoDate } from '@/portainer/filters/filters'; -import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { createOwnershipColumn } from '@/react/docker/components/datatables/createOwnershipColumn'; import { buildNameColumn } from '@@/datatables/buildNameColumn'; diff --git a/app/react/docker/configs/queries/useConfig.ts b/app/react/docker/configs/queries/useConfig.ts new file mode 100644 index 000000000..fc80798de --- /dev/null +++ b/app/react/docker/configs/queries/useConfig.ts @@ -0,0 +1,21 @@ +import { Config } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { DockerConfig } from '../types'; + +export async function getConfig( + environmentId: EnvironmentId, + configId: DockerConfig['Id'] +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'configs', configId) + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve config'); + } +} diff --git a/app/react/docker/configs/queries/useConfigs.ts b/app/react/docker/configs/queries/useConfigs.ts new file mode 100644 index 000000000..d026cb761 --- /dev/null +++ b/app/react/docker/configs/queries/useConfigs.ts @@ -0,0 +1,17 @@ +import { Config } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +export async function getConfigs(environmentId: EnvironmentId) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'configs') + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve configs'); + } +} diff --git a/app/react/docker/configs/queries/useCreateConfigMutation.ts b/app/react/docker/configs/queries/useCreateConfigMutation.ts new file mode 100644 index 000000000..329ae8af3 --- /dev/null +++ b/app/react/docker/configs/queries/useCreateConfigMutation.ts @@ -0,0 +1,22 @@ +import { ConfigSpec } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { PortainerResponse } from '../../types'; + +export async function createConfig( + environmentId: EnvironmentId, + config: ConfigSpec +) { + try { + const { data } = await axios.post>( + buildDockerProxyUrl(environmentId, 'configs', 'create'), + config + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to create config'); + } +} diff --git a/app/react/docker/configs/queries/useDeleteConfigMutation.ts b/app/react/docker/configs/queries/useDeleteConfigMutation.ts new file mode 100644 index 000000000..ef03aa44c --- /dev/null +++ b/app/react/docker/configs/queries/useDeleteConfigMutation.ts @@ -0,0 +1,16 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { DockerConfig } from '../types'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +export async function deleteConfig( + environmentId: EnvironmentId, + id: DockerConfig['Id'] +) { + try { + await axios.delete(buildDockerProxyUrl(environmentId, 'configs', id)); + } catch (e) { + throw parseAxiosError(e, 'Unable to delete config'); + } +} diff --git a/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts b/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts index f97ce1cd4..2ad144cf2 100644 --- a/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts +++ b/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts @@ -3,13 +3,13 @@ import { ResourceControlOwnership } from '@/react/portainer/access-control/types import { UserId } from '@/portainer/users/types'; import { getDefaultImageConfig } from '@/react/portainer/registries/utils/getImageConfig'; -import { ContainerResponse } from '../../queries/container'; +import { ContainerDetailsResponse } from '../../queries/useContainer'; import { toViewModel as toPortsMappingViewModel } from './PortsMappingField.viewModel'; import { Values } from './BaseForm'; export function toViewModel( - config: ContainerResponse, + config: ContainerDetailsResponse, isPureAdmin: boolean, currentUserId: UserId, nodeName: string, diff --git a/app/react/docker/containers/CreateView/CapabilitiesTab/toViewModel.ts b/app/react/docker/containers/CreateView/CapabilitiesTab/toViewModel.ts index 2f1fe3e4d..7548e9aeb 100644 --- a/app/react/docker/containers/CreateView/CapabilitiesTab/toViewModel.ts +++ b/app/react/docker/containers/CreateView/CapabilitiesTab/toViewModel.ts @@ -1,9 +1,9 @@ -import { ContainerJSON } from '@/react/docker/containers/queries/container'; +import { ContainerDetailsJSON } from '@/react/docker/containers/queries/useContainer'; import { capabilities } from './types'; import { Values } from './CapabilitiesTab'; -export function toViewModel(config: ContainerJSON): Values { +export function toViewModel(config: ContainerDetailsJSON): Values { const { CapAdd, CapDrop } = getDefaults(config); const missingCaps = capabilities @@ -15,7 +15,7 @@ export function toViewModel(config: ContainerJSON): Values { return [...CapAdd, ...missingCaps]; - function getDefaults(config: ContainerJSON) { + function getDefaults(config: ContainerDetailsJSON) { return { CapAdd: config.HostConfig?.CapAdd || [], CapDrop: config.HostConfig?.CapDrop || [], diff --git a/app/react/docker/containers/CreateView/CommandsTab/toViewModel.tsx b/app/react/docker/containers/CreateView/CommandsTab/toViewModel.tsx index a67da7692..be8017ced 100644 --- a/app/react/docker/containers/CreateView/CommandsTab/toViewModel.tsx +++ b/app/react/docker/containers/CreateView/CommandsTab/toViewModel.tsx @@ -2,7 +2,7 @@ import { HostConfig } from 'docker-types/generated/1.41'; import { commandArrayToString } from '@/docker/helpers/containers'; -import { ContainerJSON } from '../../queries/container'; +import { ContainerDetailsJSON } from '../../queries/useContainer'; import { ConsoleConfig, ConsoleSetting } from './ConsoleSettings'; import { LogConfig } from './LoggerConfig'; @@ -19,7 +19,7 @@ export function getDefaultViewModel(): Values { }; } -export function toViewModel(config: ContainerJSON): Values { +export function toViewModel(config: ContainerDetailsJSON): Values { if (!config.Config) { return getDefaultViewModel(); } diff --git a/app/react/docker/containers/CreateView/CreateView.tsx b/app/react/docker/containers/CreateView/CreateView.tsx index 76f6f3a6f..81d402c7f 100644 --- a/app/react/docker/containers/CreateView/CreateView.tsx +++ b/app/react/docker/containers/CreateView/CreateView.tsx @@ -19,7 +19,7 @@ import { InformationPanel } from '@@/InformationPanel'; import { TextTip } from '@@/Tip/TextTip'; import { HelpLink } from '@@/HelpLink'; -import { useContainers } from '../queries/containers'; +import { useContainers } from '../queries/useContainers'; import { useSystemLimits, useIsWindows } from '../../proxy/queries/useInfo'; import { useCreateOrReplaceMutation } from './useCreateMutation'; @@ -149,7 +149,21 @@ function CreateForm() { const config = toRequest(values, registry, hideCapabilities); return mutation.mutate( - { config, environment, values, registry, oldContainer, extraNetworks }, + { + config, + environment, + values: { + accessControl: values.accessControl, + imageName: values.image.image, + name: values.name, + alwaysPull: values.alwaysPull, + enableWebhook: values.enableWebhook, + nodeName: values.nodeName, + }, + registry, + oldContainer, + extraNetworks, + }, { onSuccess() { sendAnalytics(values, registry); diff --git a/app/react/docker/containers/CreateView/EnvVarsTab/toViewModel.ts b/app/react/docker/containers/CreateView/EnvVarsTab/toViewModel.ts index 1e1bb906e..63af6a12e 100644 --- a/app/react/docker/containers/CreateView/EnvVarsTab/toViewModel.ts +++ b/app/react/docker/containers/CreateView/EnvVarsTab/toViewModel.ts @@ -1,11 +1,11 @@ import { parseArrayOfStrings } from '@@/form-components/EnvironmentVariablesFieldset/utils'; -import { ContainerJSON } from '../../queries/container'; +import { ContainerDetailsJSON } from '../../queries/useContainer'; export function getDefaultViewModel() { return []; } -export function toViewModel(container: ContainerJSON) { +export function toViewModel(container: ContainerDetailsJSON) { return parseArrayOfStrings(container.Config?.Env); } diff --git a/app/react/docker/containers/CreateView/LabelsTab/toViewModel.ts b/app/react/docker/containers/CreateView/LabelsTab/toViewModel.ts index 3e1ac9fa9..8777d0131 100644 --- a/app/react/docker/containers/CreateView/LabelsTab/toViewModel.ts +++ b/app/react/docker/containers/CreateView/LabelsTab/toViewModel.ts @@ -1,8 +1,8 @@ -import { ContainerJSON } from '../../queries/container'; +import { ContainerDetailsJSON } from '../../queries/useContainer'; import { Values } from './types'; -export function toViewModel(config: ContainerJSON): Values { +export function toViewModel(config: ContainerDetailsJSON): Values { if (!config || !config.Config || !config.Config.Labels) { return []; } diff --git a/app/react/docker/containers/CreateView/NetworkTab/ContainerSelector.tsx b/app/react/docker/containers/CreateView/NetworkTab/ContainerSelector.tsx index e93da7ec9..e05cb10c6 100644 --- a/app/react/docker/containers/CreateView/NetworkTab/ContainerSelector.tsx +++ b/app/react/docker/containers/CreateView/NetworkTab/ContainerSelector.tsx @@ -2,7 +2,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { Option, PortainerSelect } from '@@/form-components/PortainerSelect'; -import { useContainers } from '../../queries/containers'; +import { useContainers } from '../../queries/useContainers'; import { ContainerStatus } from '../../types'; export function ContainerSelector({ diff --git a/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts index b73fdfe69..4adc3baab 100644 --- a/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts +++ b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts @@ -1,7 +1,7 @@ import { DockerNetwork } from '@/react/docker/networks/types'; -import { ContainerJSON } from '../../queries/container'; -import { DockerContainer } from '../../types'; +import { ContainerDetailsJSON } from '../../queries/useContainer'; +import { ContainerListViewModel } from '../../types'; import { CONTAINER_MODE, Values } from './types'; @@ -22,9 +22,9 @@ export function getDefaultViewModel(isWindows: boolean) { } export function toViewModel( - config: ContainerJSON, + config: ContainerDetailsJSON, networks: Array, - runningContainers: Array = [] + runningContainers: Array = [] ): Values { const dns = config.HostConfig?.Dns; const [primaryDns = '', secondaryDns = ''] = dns || []; @@ -62,9 +62,9 @@ export function toViewModel( } function getNetworkMode( - config: ContainerJSON, + config: ContainerDetailsJSON, networks: Array, - runningContainers: Array = [] + runningContainers: Array = [] ) { let networkMode = config.HostConfig?.NetworkMode || ''; if (!networkMode) { diff --git a/app/react/docker/containers/CreateView/ResourcesTab/EditResourceForm.tsx b/app/react/docker/containers/CreateView/ResourcesTab/EditResourceForm.tsx index 9b9c35b78..4d395fbd8 100644 --- a/app/react/docker/containers/CreateView/ResourcesTab/EditResourceForm.tsx +++ b/app/react/docker/containers/CreateView/ResourcesTab/EditResourceForm.tsx @@ -4,7 +4,7 @@ import { useCurrentStateAndParams } from '@uirouter/react'; import { useState } from 'react'; import { FormikHelpers } from 'formik/dist/types'; -import { invalidateContainer } from '@/react/docker/containers/queries/container'; +import { invalidateContainer } from '@/react/docker/containers/queries/useContainer'; import { notifySuccess } from '@/portainer/services/notifications'; import { mutationOptions, withError } from '@/react-tools/react-query'; import { useSystemLimits } from '@/react/docker/proxy/queries/useInfo'; diff --git a/app/react/docker/containers/CreateView/ResourcesTab/toViewModel.ts b/app/react/docker/containers/CreateView/ResourcesTab/toViewModel.ts index c79c6a664..9f15d5e63 100644 --- a/app/react/docker/containers/CreateView/ResourcesTab/toViewModel.ts +++ b/app/react/docker/containers/CreateView/ResourcesTab/toViewModel.ts @@ -1,11 +1,11 @@ -import { ContainerJSON } from '../../queries/container'; +import { ContainerDetailsJSON } from '../../queries/useContainer'; import { toDevicesViewModel } from './DevicesField'; import { gpuFieldsetUtils } from './GpuFieldset'; import { toViewModelCpu, toViewModelMemory } from './memory-utils'; import { Values } from './ResourcesTab'; -export function toViewModel(config: ContainerJSON): Values { +export function toViewModel(config: ContainerDetailsJSON): Values { return { runtime: { privileged: config.HostConfig?.Privileged || false, diff --git a/app/react/docker/containers/CreateView/RestartPolicyTab/toViewModel.ts b/app/react/docker/containers/CreateView/RestartPolicyTab/toViewModel.ts index 884e34509..bdd8a341d 100644 --- a/app/react/docker/containers/CreateView/RestartPolicyTab/toViewModel.ts +++ b/app/react/docker/containers/CreateView/RestartPolicyTab/toViewModel.ts @@ -1,8 +1,8 @@ -import { ContainerJSON } from '../../queries/container'; +import { ContainerDetailsJSON } from '../../queries/useContainer'; import { RestartPolicy } from './types'; -export function toViewModel(config: ContainerJSON): RestartPolicy { +export function toViewModel(config: ContainerDetailsJSON): RestartPolicy { switch (config.HostConfig?.RestartPolicy?.Name) { case 'always': return RestartPolicy.Always; diff --git a/app/react/docker/containers/CreateView/VolumesTab/toViewModel.ts b/app/react/docker/containers/CreateView/VolumesTab/toViewModel.ts index f4df17be1..660b69611 100644 --- a/app/react/docker/containers/CreateView/VolumesTab/toViewModel.ts +++ b/app/react/docker/containers/CreateView/VolumesTab/toViewModel.ts @@ -1,8 +1,8 @@ -import { ContainerJSON } from '../../queries/container'; +import { ContainerDetailsJSON } from '../../queries/useContainer'; import { VolumeType, Values } from './types'; -export function toViewModel(config: ContainerJSON): Values { +export function toViewModel(config: ContainerDetailsJSON): Values { return Object.values(config.Mounts || {}).map((mount) => ({ type: (mount.Type || 'volume') as VolumeType, name: mount.Name || mount.Source || '', diff --git a/app/react/docker/containers/CreateView/useCreateMutation.tsx b/app/react/docker/containers/CreateView/useCreateMutation.tsx index 54c8601c2..420daabc1 100644 --- a/app/react/docker/containers/CreateView/useCreateMutation.tsx +++ b/app/react/docker/containers/CreateView/useCreateMutation.tsx @@ -1,5 +1,4 @@ import { useMutation, useQueryClient } from 'react-query'; -import { RawAxiosRequestHeaders } from 'axios'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { @@ -32,12 +31,13 @@ import { renameContainer, startContainer, stopContainer, - urlBuilder, } from '../containers.service'; import { PortainerResponse } from '../../types'; -import { connectContainer } from '../../networks/queries/useConnectContainer'; -import { DockerContainer } from '../types'; +import { connectContainer } from '../../networks/queries/useConnectContainerMutation'; +import { ContainerListViewModel } from '../types'; import { queryKeys } from '../queries/query-keys'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; import { CreateContainerRequest } from './types'; import { Values } from './useInitialValues'; @@ -62,13 +62,20 @@ export function useCreateOrReplaceMutation() { interface CreateOptions { config: CreateContainerRequest; - values: Values; + values: { + name: Values['name']; + imageName: string; + accessControl: Values['accessControl']; + nodeName?: Values['nodeName']; + alwaysPull?: Values['alwaysPull']; + enableWebhook?: Values['enableWebhook']; + }; registry?: Registry; environment: Environment; } interface ReplaceOptions extends CreateOptions { - oldContainer: DockerContainer; + oldContainer: ContainerListViewModel; extraNetworks: Array; } @@ -90,14 +97,14 @@ async function create({ }: CreateOptions) { await pullImageIfNeeded( environment.Id, + values.alwaysPull || false, + values.imageName, values.nodeName, - values.alwaysPull, - values.image.image, registry ); const containerResponse = await createAndStart( - environment, + environment.Id, config, values.name, values.nodeName @@ -106,8 +113,8 @@ async function create({ await applyContainerSettings( containerResponse.Id, environment, - values.enableWebhook, values.accessControl, + values.enableWebhook, containerResponse.Portainer?.ResourceControl, registry ); @@ -123,37 +130,38 @@ async function replace({ }: ReplaceOptions) { await pullImageIfNeeded( environment.Id, + values.alwaysPull || false, + values.imageName, values.nodeName, - values.alwaysPull, - values.image.image, registry ); const containerResponse = await renameAndCreate( - environment, - values, + environment.Id, + values.name, oldContainer, - config + config, + values.nodeName ); await applyContainerSettings( containerResponse.Id, environment, - values.enableWebhook, values.accessControl, + values.enableWebhook, containerResponse.Portainer?.ResourceControl, registry ); await connectToExtraNetworks( environment.Id, - values.nodeName, containerResponse.Id, - extraNetworks + extraNetworks, + values.nodeName ); await removeContainer(environment.Id, oldContainer.Id, { - nodeName: values.nodeName, + nodeName: oldContainer.NodeName, }); } @@ -162,33 +170,33 @@ async function replace({ * on any failure, it will rename the old container to its original name */ async function renameAndCreate( - environment: Environment, - values: Values, - oldContainer: DockerContainer, - config: CreateContainerRequest + environmentId: EnvironmentId, + name: string, + oldContainer: ContainerListViewModel, + config: CreateContainerRequest, + nodeName?: string ) { let renamed = false; try { - await stopContainerIfNeeded(environment.Id, values.nodeName, oldContainer); + await stopContainerIfNeeded( + environmentId, + oldContainer, + oldContainer.NodeName + ); await renameContainer( - environment.Id, + environmentId, oldContainer.Id, `${oldContainer.Names[0]}-old`, - { nodeName: values.nodeName } + { nodeName: oldContainer.NodeName } ); renamed = true; - return await createAndStart( - environment, - config, - values.name, - values.nodeName - ); + return await createAndStart(environmentId, config, name, nodeName); } catch (e) { if (renamed) { - await renameContainer(environment.Id, oldContainer.Id, values.name, { - nodeName: values.nodeName, + await renameContainer(environmentId, oldContainer.Id, name, { + nodeName: oldContainer.NodeName, }); } throw e; @@ -201,8 +209,8 @@ async function renameAndCreate( async function applyContainerSettings( containerId: string, environment: Environment, - enableWebhook: boolean, accessControl: AccessControlFormData, + enableWebhook?: boolean, resourceControl?: ResourceControlResponse, registry?: Registry ) { @@ -224,15 +232,15 @@ async function applyContainerSettings( * on failure, it will remove the new container */ async function createAndStart( - environment: Environment, + environmentId: EnvironmentId, config: CreateContainerRequest, - name: string, - nodeName: string + name?: string, + nodeName?: string ) { let containerId = ''; try { const containerResponse = await createContainer( - environment.Id, + environmentId, config, name, { @@ -242,11 +250,11 @@ async function createAndStart( containerId = containerResponse.Id; - await startContainer(environment.Id, containerResponse.Id, { nodeName }); + await startContainer(environmentId, containerResponse.Id, { nodeName }); return containerResponse; } catch (e) { if (containerId) { - await removeContainer(environment.Id, containerId, { + await removeContainer(environmentId, containerId, { nodeName, }); } @@ -257,9 +265,9 @@ async function createAndStart( async function pullImageIfNeeded( environmentId: EnvironmentId, - nodeName: string, pull: boolean, image: string, + nodeName?: string, registry?: Registry ) { if (!pull) { @@ -282,16 +290,10 @@ async function createContainer( { nodeName }: { nodeName?: string } = {} ) { try { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; - } - const { data } = await axios.post< PortainerResponse<{ Id: string; Warnings: Array }> - >(urlBuilder(environmentId, undefined, 'create'), config, { - headers, + >(buildDockerProxyUrl(environmentId, 'containers', 'create'), config, { + headers: { ...withAgentTargetHeader(nodeName) }, params: { name }, }); @@ -322,9 +324,9 @@ async function createContainerWebhook( function connectToExtraNetworks( environmentId: EnvironmentId, - nodeName: string, containerId: string, - extraNetworks: Array + extraNetworks: Array, + nodeName?: string ) { if (!extraNetworks) { return null; @@ -345,8 +347,8 @@ function connectToExtraNetworks( function stopContainerIfNeeded( environmentId: EnvironmentId, - nodeName: string, - container: DockerContainer + container: ContainerListViewModel, + nodeName?: string ) { if (container.State !== 'running' || !container.Id) { return null; diff --git a/app/react/docker/containers/CreateView/useInitialValues.ts b/app/react/docker/containers/CreateView/useInitialValues.ts index efdb4523d..ca97957b9 100644 --- a/app/react/docker/containers/CreateView/useInitialValues.ts +++ b/app/react/docker/containers/CreateView/useInitialValues.ts @@ -43,8 +43,8 @@ import { useEnvironmentRegistries } from '@/react/portainer/environments/queries import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset'; import { useNetworksForSelector } from '../components/NetworkSelector'; -import { useContainers } from '../queries/containers'; -import { useContainer } from '../queries/container'; +import { useContainers } from '../queries/useContainers'; +import { useContainer } from '../queries/useContainer'; export interface Values extends BaseFormValues { commands: CommandsTabValues; diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ConnectNetworkForm.tsx b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ConnectNetworkForm.tsx index 4b6da4c9b..f55b2e81a 100644 --- a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ConnectNetworkForm.tsx +++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ConnectNetworkForm.tsx @@ -3,7 +3,7 @@ import { SchemaOf, object, string } from 'yup'; import { useRouter } from '@uirouter/react'; import { useAuthorizations } from '@/react/hooks/useUser'; -import { useConnectContainerMutation } from '@/react/docker/networks/queries/useConnectContainer'; +import { useConnectContainerMutation } from '@/react/docker/networks/queries/useConnectContainerMutation'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { FormControl } from '@@/form-components/FormControl'; diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ContainerNetworksDatatable.tsx b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ContainerNetworksDatatable.tsx index 183a638c3..fab7a23cd 100644 --- a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ContainerNetworksDatatable.tsx +++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ContainerNetworksDatatable.tsx @@ -7,7 +7,7 @@ import { useTableState } from '@@/datatables/useTableState'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; import { withMeta } from '@@/datatables/extend-options/withMeta'; -import { DockerContainer } from '../../types'; +import { ContainerListViewModel } from '../../types'; import { TableNetwork } from './types'; import { buildColumns } from './columns'; @@ -22,7 +22,7 @@ export function ContainerNetworksDatatable({ nodeName, }: { dataset: NetworkSettings['Networks']; - container: DockerContainer; + container: ContainerListViewModel; nodeName?: string; }) { const tableState = useTableState(store, storageKey); diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/actions.tsx b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/actions.tsx index b003cb69d..795988b5e 100644 --- a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/actions.tsx +++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/actions.tsx @@ -2,8 +2,9 @@ import { CellContext } from '@tanstack/react-table'; import { useRouter } from '@uirouter/react'; import { Authorized } from '@/react/hooks/useUser'; -import { useDisconnectContainer } from '@/react/docker/networks/queries'; +import { useDisconnectContainer } from '@/react/docker/networks/queries/useDisconnectContainerMutation'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { notifySuccess } from '@/portainer/services/notifications'; import { LoadingButton } from '@@/buttons'; @@ -17,19 +18,25 @@ export function buildActions({ nodeName }: { nodeName?: string } = {}) { }); function Cell({ - row, + row: { + original: { id: networkId }, + }, table: { options: { meta }, }, }: CellContext) { const router = useRouter(); const environmentId = useEnvironmentId(); - const disconnectMutation = useDisconnectContainer(); + const disconnectMutation = useDisconnectContainer({ + environmentId, + networkId, + }); return ( ( <> - + table={tableInstance} onChange={(hiddenColumns) => { tableState.setHiddenColumns(hiddenColumns); diff --git a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx index 360cc333f..07a9cf386 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx @@ -16,7 +16,7 @@ import { setPortainerAgentTargetHeader } from '@/portainer/services/http-request import { ContainerId, ContainerStatus, - DockerContainer, + ContainerListViewModel, } from '@/react/docker/containers/types'; import { killContainer, @@ -38,7 +38,7 @@ type ContainerServiceAction = ( ) => Promise; interface Props { - selectedItems: DockerContainer[]; + selectedItems: ContainerListViewModel[]; isAddActionVisible: boolean; endpointId: EnvironmentId; } @@ -175,7 +175,7 @@ export function ContainersDatatableActions({ ); - function onStartClick(selectedItems: DockerContainer[]) { + function onStartClick(selectedItems: ContainerListViewModel[]) { const successMessage = 'Container successfully started'; const errorMessage = 'Unable to start container'; executeActionOnContainerList( @@ -186,7 +186,7 @@ export function ContainersDatatableActions({ ); } - function onStopClick(selectedItems: DockerContainer[]) { + function onStopClick(selectedItems: ContainerListViewModel[]) { const successMessage = 'Container successfully stopped'; const errorMessage = 'Unable to stop container'; executeActionOnContainerList( @@ -197,7 +197,7 @@ export function ContainersDatatableActions({ ); } - function onRestartClick(selectedItems: DockerContainer[]) { + function onRestartClick(selectedItems: ContainerListViewModel[]) { const successMessage = 'Container successfully restarted'; const errorMessage = 'Unable to restart container'; executeActionOnContainerList( @@ -208,7 +208,7 @@ export function ContainersDatatableActions({ ); } - function onKillClick(selectedItems: DockerContainer[]) { + function onKillClick(selectedItems: ContainerListViewModel[]) { const successMessage = 'Container successfully killed'; const errorMessage = 'Unable to kill container'; executeActionOnContainerList( @@ -219,7 +219,7 @@ export function ContainersDatatableActions({ ); } - function onPauseClick(selectedItems: DockerContainer[]) { + function onPauseClick(selectedItems: ContainerListViewModel[]) { const successMessage = 'Container successfully paused'; const errorMessage = 'Unable to pause container'; executeActionOnContainerList( @@ -230,7 +230,7 @@ export function ContainersDatatableActions({ ); } - function onResumeClick(selectedItems: DockerContainer[]) { + function onResumeClick(selectedItems: ContainerListViewModel[]) { const successMessage = 'Container successfully resumed'; const errorMessage = 'Unable to resume container'; executeActionOnContainerList( @@ -241,7 +241,7 @@ export function ContainersDatatableActions({ ); } - async function onRemoveClick(selectedItems: DockerContainer[]) { + async function onRemoveClick(selectedItems: ContainerListViewModel[]) { const isOneContainerRunning = selectedItems.some( (container) => container.State === 'running' ); @@ -259,7 +259,7 @@ export function ContainersDatatableActions({ } async function executeActionOnContainerList( - containers: DockerContainer[], + containers: ContainerListViewModel[], action: ContainerServiceAction, successMessage: string, errorMessage: string @@ -283,7 +283,7 @@ export function ContainersDatatableActions({ } async function removeSelectedContainers( - containers: DockerContainer[], + containers: ContainerListViewModel[], removeVolumes: boolean ) { for (let i = 0; i < containers.length; i += 1) { diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/gpus.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/gpus.tsx index c77bc41a0..fbf83baa8 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/gpus.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/gpus.tsx @@ -1,6 +1,6 @@ import { CellContext } from '@tanstack/react-table'; -import type { DockerContainer } from '@/react/docker/containers/types'; +import type { ContainerListViewModel } from '@/react/docker/containers/types'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useContainerGpus } from '@/react/docker/containers/queries/gpus'; @@ -14,7 +14,7 @@ export const gpus = columnHelper.display({ function GpusCell({ row: { original: container }, -}: CellContext) { +}: CellContext) { const containerId = container.Id; const environmentId = useEnvironmentId(); const gpusQuery = useContainerGpus(environmentId, containerId); diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/helper.ts b/app/react/docker/containers/ListView/ContainersDatatable/columns/helper.ts index 710a06852..34e383349 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/helper.ts +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/helper.ts @@ -1,5 +1,5 @@ import { createColumnHelper } from '@tanstack/react-table'; -import { DockerContainer } from '../../../types'; +import { ContainerListViewModel } from '../../../types'; -export const columnHelper = createColumnHelper(); +export const columnHelper = createColumnHelper(); diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/image.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/image.tsx index 98c6d26ab..23971fb50 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/image.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/image.tsx @@ -1,7 +1,7 @@ import { CellContext } from '@tanstack/react-table'; import { useSref } from '@uirouter/react'; -import type { DockerContainer } from '@/react/docker/containers/types'; +import type { ContainerListViewModel } from '@/react/docker/containers/types'; import { columnHelper } from './helper'; @@ -11,7 +11,7 @@ export const image = columnHelper.accessor('Image', { cell: ImageCell, }); -function ImageCell({ getValue }: CellContext) { +function ImageCell({ getValue }: CellContext) { const imageName = getValue(); const linkProps = useSref('docker.images.image', { id: imageName }); const shortImageName = trimSHASum(imageName); diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx index b1ec5bb69..d2a1b4f34 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx @@ -1,8 +1,8 @@ import _ from 'lodash'; import { useMemo } from 'react'; -import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn'; -import { DockerContainer } from '@/react/docker/containers/types'; +import { createOwnershipColumn } from '@/react/docker/components/datatables/createOwnershipColumn'; +import { ContainerListViewModel } from '@/react/docker/containers/types'; import { created } from './created'; import { host } from './host'; @@ -32,7 +32,7 @@ export function useColumns( isHostColumnVisible && host, isGPUsColumnVisible && gpus, ports, - createOwnershipColumn(), + createOwnershipColumn(), ]), [isHostColumnVisible, isGPUsColumnVisible] ); diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/name.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/name.tsx index f0ff50bba..bb4263a30 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/name.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/name.tsx @@ -2,7 +2,7 @@ import { CellContext } from '@tanstack/react-table'; import _ from 'lodash'; import { useSref } from '@uirouter/react'; -import type { DockerContainer } from '@/react/docker/containers/types'; +import type { ContainerListViewModel } from '@/react/docker/containers/types'; import { useTableSettings } from '@@/datatables/useTableSettings'; @@ -19,7 +19,7 @@ export const name = columnHelper.accessor((row) => row.Names[0], { export function NameCell({ getValue, row: { original: container }, -}: CellContext) { +}: CellContext) { const name = getValue(); const linkProps = useSref('.container', { diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/ports.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/ports.tsx index 946789340..00045b809 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/ports.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/ports.tsx @@ -1,8 +1,8 @@ import _ from 'lodash'; import { CellContext } from '@tanstack/react-table'; -import type { DockerContainer } from '@/react/docker/containers/types'; import { PublishedPortLink } from '@/react/docker/components/ImageStatus/PublishedPortLink'; +import type { ContainerListViewModel } from '@/react/docker/containers/types'; import { useRowContext } from '../RowContext'; @@ -20,7 +20,7 @@ export const ports = columnHelper.accessor( } ); -function Cell({ row }: CellContext) { +function Cell({ row }: CellContext) { const ports = row.original.Ports; const { environment } = useRowContext(); diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/quick-actions.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/quick-actions.tsx index 63054fcd2..429ec2652 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/quick-actions.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/quick-actions.tsx @@ -2,7 +2,7 @@ import { CellContext } from '@tanstack/react-table'; import { useAuthorizations } from '@/react/hooks/useUser'; import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions'; -import { DockerContainer } from '@/react/docker/containers/types'; +import { ContainerListViewModel } from '@/react/docker/containers/types'; import { useTableSettings } from '@@/datatables/useTableSettings'; @@ -18,7 +18,7 @@ export const quickActions = columnHelper.display({ function QuickActionsCell({ row: { original: container }, -}: CellContext) { +}: CellContext) { const settings = useTableSettings(); const { hiddenQuickActions = [] } = settings; diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/state.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/state.tsx index 9d62fa453..350e232d8 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/state.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/state.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import { CellContext } from '@tanstack/react-table'; import { - type DockerContainer, + type ContainerListViewModel, ContainerStatus, } from '@/react/docker/containers/types'; @@ -25,7 +25,7 @@ export const state = columnHelper.accessor('Status', { function StatusCell({ getValue, row: { original: container }, -}: CellContext) { +}: CellContext) { const status = getValue(); const hasHealthCheck = [ diff --git a/app/react/docker/containers/LogView/LogView.tsx b/app/react/docker/containers/LogView/LogView.tsx index 0c763d9e4..b1e7860a2 100644 --- a/app/react/docker/containers/LogView/LogView.tsx +++ b/app/react/docker/containers/LogView/LogView.tsx @@ -1,6 +1,6 @@ import { useCurrentStateAndParams } from '@uirouter/react'; -import { useContainer } from '@/react/docker/containers/queries/container'; +import { useContainer } from '@/react/docker/containers/queries/useContainer'; import { InformationPanel } from '@@/InformationPanel'; import { TextTip } from '@@/Tip/TextTip'; diff --git a/app/react/docker/containers/containers.service.ts b/app/react/docker/containers/containers.service.ts index f9e2e1cda..4fb2b3f96 100644 --- a/app/react/docker/containers/containers.service.ts +++ b/app/react/docker/containers/containers.service.ts @@ -1,28 +1,27 @@ -import { RawAxiosRequestHeaders } from 'axios'; +import _ from 'lodash'; import { EnvironmentId } from '@/react/portainer/environments/types'; import PortainerError from '@/portainer/error'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { genericHandler } from '@/docker/rest/response/handlers'; -import { ContainerId } from './types'; +import { withAgentTargetHeader } from '../proxy/queries/utils'; +import { buildDockerProxyUrl } from '../proxy/queries/buildDockerProxyUrl'; +import { buildDockerUrl } from '../queries/utils/buildDockerUrl'; + +import { ContainerId, ContainerLogsParams } from './types'; export async function startContainer( environmentId: EnvironmentId, id: ContainerId, { nodeName }: { nodeName?: string } = {} ) { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; - } - try { await axios.post( - urlBuilder(environmentId, id, 'start'), + buildDockerProxyUrl(environmentId, 'containers', id, 'start'), {}, - { transformResponse: genericHandler, headers } + { + headers: { ...withAgentTargetHeader(nodeName) }, + } ); } catch (e) { throw parseAxiosError(e, 'Failed starting container'); @@ -34,13 +33,15 @@ export async function stopContainer( id: ContainerId, { nodeName }: { nodeName?: string } = {} ) { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; + try { + await axios.post( + buildDockerProxyUrl(endpointId, 'containers', id, 'stop'), + {}, + { headers: { ...withAgentTargetHeader(nodeName) } } + ); + } catch (e) { + throw parseAxiosError(e, 'Failed stopping container'); } - - await axios.post(urlBuilder(endpointId, id, 'stop'), {}, { headers }); } export async function recreateContainer( @@ -49,19 +50,17 @@ export async function recreateContainer( pullImage: boolean, { nodeName }: { nodeName?: string } = {} ) { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; + try { + await axios.post( + buildDockerUrl(endpointId, 'containers', id, 'recreate'), + { + PullImage: pullImage, + }, + { headers: { ...withAgentTargetHeader(nodeName) } } + ); + } catch (e) { + throw parseAxiosError(e, 'Failed recreating container'); } - - await axios.post( - `/docker/${endpointId}/containers/${id}/recreate`, - { - PullImage: pullImage, - }, - { headers } - ); } export async function restartContainer( @@ -69,17 +68,15 @@ export async function restartContainer( id: ContainerId, { nodeName }: { nodeName?: string } = {} ) { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; + try { + await axios.post( + buildDockerProxyUrl(endpointId, 'containers', id, 'restart'), + {}, + { headers: { ...withAgentTargetHeader(nodeName) } } + ); + } catch (e) { + throw parseAxiosError(e, 'Failed restarting container'); } - - await axios.post( - urlBuilder(endpointId, id, 'restart'), - {}, - { headers } - ); } export async function killContainer( @@ -87,13 +84,15 @@ export async function killContainer( id: ContainerId, { nodeName }: { nodeName?: string } = {} ) { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; + try { + await axios.post( + buildDockerProxyUrl(endpointId, 'containers', id, 'kill'), + {}, + { headers: { ...withAgentTargetHeader(nodeName) } } + ); + } catch (e) { + throw parseAxiosError(e, 'Failed killing container'); } - - await axios.post(urlBuilder(endpointId, id, 'kill'), {}, { headers }); } export async function pauseContainer( @@ -101,13 +100,15 @@ export async function pauseContainer( id: ContainerId, { nodeName }: { nodeName?: string } = {} ) { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; + try { + await axios.post( + buildDockerProxyUrl(endpointId, 'containers', id, 'pause'), + {}, + { headers: { ...withAgentTargetHeader(nodeName) } } + ); + } catch (e) { + throw parseAxiosError(e, 'Failed pausing container'); } - - await axios.post(urlBuilder(endpointId, id, 'pause'), {}, { headers }); } export async function resumeContainer( @@ -115,17 +116,15 @@ export async function resumeContainer( id: ContainerId, { nodeName }: { nodeName?: string } = {} ) { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; + try { + await axios.post( + buildDockerProxyUrl(endpointId, 'containers', id, 'unpause'), + {}, + { headers: { ...withAgentTargetHeader(nodeName) } } + ); + } catch (e) { + throw parseAxiosError(e, 'Failed resuming container'); } - - await axios.post( - urlBuilder(endpointId, id, 'unpause'), - {}, - { headers } - ); } export async function renameContainer( @@ -134,17 +133,18 @@ export async function renameContainer( name: string, { nodeName }: { nodeName?: string } = {} ) { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; + try { + await axios.post( + buildDockerProxyUrl(endpointId, 'containers', id, 'rename'), + {}, + { + params: { name }, + headers: { ...withAgentTargetHeader(nodeName) }, + } + ); + } catch (e) { + throw parseAxiosError(e, 'Failed renaming container'); } - - await axios.post( - urlBuilder(endpointId, id, 'rename'), - {}, - { params: { name }, transformResponse: genericHandler, headers } - ); } export async function removeContainer( @@ -156,18 +156,11 @@ export async function removeContainer( }: { removeVolumes?: boolean; nodeName?: string } = {} ) { try { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; - } - const { data } = await axios.delete( - urlBuilder(endpointId, containerId), + buildDockerProxyUrl(endpointId, 'containers', containerId), { params: { v: removeVolumes ? 1 : 0, force: true }, - transformResponse: genericHandler, - headers, + headers: { ...withAgentTargetHeader(nodeName) }, } ); @@ -175,24 +168,25 @@ export async function removeContainer( throw new PortainerError(data.message); } } catch (e) { - throw new PortainerError('Unable to remove container', e as Error); + throw parseAxiosError(e, 'Unable to remove container'); } } -export function urlBuilder( - endpointId: EnvironmentId, - id?: ContainerId, - action?: string -) { - let url = `/endpoints/${endpointId}/docker/containers`; +export async function getContainerLogs( + environmentId: EnvironmentId, + containerId: ContainerId, + params?: ContainerLogsParams +): Promise { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'containers', containerId, 'logs'), + { + params: _.pickBy(params), + } + ); - if (id) { - url += `/${id}`; + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to get container logs'); } - - if (action) { - url += `/${action}`; - } - - return url; } diff --git a/app/react/docker/containers/queries/container.ts b/app/react/docker/containers/queries/useContainer.ts similarity index 75% rename from app/react/docker/containers/queries/container.ts rename to app/react/docker/containers/queries/useContainer.ts index 383058110..87f9b1512 100644 --- a/app/react/docker/containers/queries/container.ts +++ b/app/react/docker/containers/queries/useContainer.ts @@ -7,7 +7,6 @@ import { MountPoint, NetworkSettings, } from 'docker-types/generated/1.41'; -import { RawAxiosRequestHeaders } from 'axios'; import { PortainerResponse } from '@/react/docker/types'; import axios, { parseAxiosError } from '@/portainer/services/axios'; @@ -15,11 +14,15 @@ import { ContainerId } from '@/react/docker/containers/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { queryClient } from '@/react-tools/react-query'; -import { urlBuilder } from '../containers.service'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; import { queryKeys } from './query-keys'; -export interface ContainerJSON { +/** + * Raw Docker Container Details response + */ +export interface ContainerDetailsJSON { /** * The ID of the container */ @@ -83,7 +86,7 @@ export function useContainer( containerId ? queryKeys.container(environmentId, containerId) : [], () => containerId - ? getContainer(environmentId, containerId, nodeName) + ? getContainer(environmentId, containerId, { nodeName }) : undefined, { meta: { @@ -104,23 +107,28 @@ export function invalidateContainer( ); } -export type ContainerResponse = PortainerResponse; +export type ContainerDetailsResponse = PortainerResponse; -async function getContainer( +/** + * Raw docker API proxy + * @param environmentId + * @param id + * @param param2 + * @returns + */ +export async function getContainer( environmentId: EnvironmentId, - containerId: ContainerId, - nodeName?: string + id: ContainerId, + { nodeName }: { nodeName?: string } = {} ) { try { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; - } - - const { data } = await axios.get( - urlBuilder(environmentId, containerId, 'json'), - { headers } + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'containers', id, 'json'), + { + headers: { + ...withAgentTargetHeader(nodeName), + }, + } ); return data; } catch (error) { diff --git a/app/react/docker/containers/queries/useContainerInspect.ts b/app/react/docker/containers/queries/useContainerInspect.ts new file mode 100644 index 000000000..0d48c040b --- /dev/null +++ b/app/react/docker/containers/queries/useContainerInspect.ts @@ -0,0 +1,38 @@ +import { useQuery } from 'react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { ContainerId } from '../types'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +import { queryKeys } from './query-keys'; +import { ContainerDetailsJSON } from './useContainer'; + +export function useContainerInspect( + environmentId: EnvironmentId, + id: ContainerId, + params: { nodeName?: string } = {} +) { + return useQuery({ + queryKey: [...queryKeys.container(environmentId, id), params] as const, + queryFn: () => inspectContainer(environmentId, id, params), + }); +} + +export async function inspectContainer( + environmentId: EnvironmentId, + id: ContainerId, + { nodeName }: { nodeName?: string } = {} +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'containers', id, 'json'), + { headers: { ...withAgentTargetHeader(nodeName) } } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Failed inspecting container'); + } +} diff --git a/app/react/docker/containers/queries/useContainerResizeTTYMutation.ts b/app/react/docker/containers/queries/useContainerResizeTTYMutation.ts new file mode 100644 index 000000000..d3bf67477 --- /dev/null +++ b/app/react/docker/containers/queries/useContainerResizeTTYMutation.ts @@ -0,0 +1,27 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param id exec instance id + */ +export async function resizeTTY( + environmentId: EnvironmentId, + id: string, + { width, height }: { width: number; height: number } +) { + try { + await axios.post( + buildDockerProxyUrl(environmentId, 'containers', id, 'resize'), + {}, + { + params: { h: height, w: width }, + } + ); + } catch (err) { + throw parseAxiosError(err, 'Unable to resize tty of container'); + } +} diff --git a/app/react/docker/containers/queries/useContainerStats.ts b/app/react/docker/containers/queries/useContainerStats.ts new file mode 100644 index 000000000..de117a101 --- /dev/null +++ b/app/react/docker/containers/queries/useContainerStats.ts @@ -0,0 +1,136 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { ContainerId } from '../types'; + +/** + * This type is arbitrary and only defined based on what we use / observed from the API responses. + */ +export type ContainerStats = { + name?: string; + id?: string; + read?: string; + preread?: string; + pids_stats?: { + current?: number; + limit?: number; + }; + memory_stats?: MemoryStats; + num_procs?: number; + precpu_stats?: CpuStats; + cpu_stats?: CpuStats; + networks?: Record; + blkio_stats?: BlkioStats; + storage_stats?: unknown; +}; + +/** + * Raw docker API proxy + * @param environmentId + * @param id + * @returns + */ +export async function containerStats( + environmentId: EnvironmentId, + id: ContainerId +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'containers', id, 'stats'), + { params: { stream: false } } + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to retrieve container stats'); + } +} + +type BlkioStats = { + io_service_bytes_recursive?: { + major: number; + minor: number; + op: string; + value: number; + }[]; + io_serviced_recursive?: null; + io_queue_recursive?: null; + io_service_time_recursive?: null; + io_wait_time_recursive?: null; + io_merged_recursive?: null; + io_time_recursive?: null; + sectors_recursive?: null; +}; + +type NetworkStats = { + rx_bytes?: number; + rx_packets?: number; + rx_errors?: number; + rx_dropped?: number; + tx_bytes?: number; + tx_packets?: number; + tx_errors?: number; + tx_dropped?: number; +}; + +type MemoryStats = { + privateworkingset?: number; + usage?: number; + stats?: MemoryStatsStats; + limit?: number; +}; + +type MemoryStatsStats = { + active_anon?: number; + active_file?: number; + anon?: number; + anon_thp?: number; + cache?: number; + file?: number; + file_dirty?: number; + file_mapped?: number; + file_writeback?: number; + inactive_anon?: number; + inactive_file?: number; + kernel_stack?: number; + pgactivate?: number; + pgdeactivate?: number; + pgfault?: number; + pglazyfree?: number; + pglazyfreed?: number; + pgmajfault?: number; + pgrefill?: number; + pgscan?: number; + pgsteal?: number; + shmem?: number; + slab?: number; + slab_reclaimable?: number; + slab_unreclaimable?: number; + sock?: number; + thp_collapse_alloc?: number; + thp_fault_alloc?: number; + unevictable?: number; + workingset_activate?: number; + workingset_nodereclaim?: number; + workingset_refault?: number; +}; + +type CpuUsage = { + total_usage?: number; + usage_in_kernelmode?: number; + usage_in_usermode?: number; + percpu_usage?: number[]; +}; + +type ThrottlingData = { + periods?: number; + throttled_periods?: number; + throttled_time?: number; +}; + +type CpuStats = { + cpu_usage?: CpuUsage; + system_cpu_usage?: number; + online_cpus?: number; + throttling_data?: ThrottlingData; +}; diff --git a/app/react/docker/containers/queries/useContainerTop.ts b/app/react/docker/containers/queries/useContainerTop.ts new file mode 100644 index 000000000..26c1b7734 --- /dev/null +++ b/app/react/docker/containers/queries/useContainerTop.ts @@ -0,0 +1,25 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { ContainerId } from '../types'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param id + * @returns + */ +export async function containerTop( + environmentId: EnvironmentId, + id: ContainerId +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'containers', id, 'top') + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to retrieve container top'); + } +} diff --git a/app/react/docker/containers/queries/containers.ts b/app/react/docker/containers/queries/useContainers.ts similarity index 61% rename from app/react/docker/containers/queries/containers.ts rename to app/react/docker/containers/queries/useContainers.ts index 1c6c2b6ba..9dab05d35 100644 --- a/app/react/docker/containers/queries/containers.ts +++ b/app/react/docker/containers/queries/useContainers.ts @@ -1,16 +1,17 @@ import { useQuery } from 'react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import axios, { - agentTargetHeader, - parseAxiosError, -} from '@/portainer/services/axios'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; import { withGlobalError } from '@/react-tools/react-query'; -import { urlBuilder } from '../containers.service'; import { DockerContainerResponse } from '../types/response'; import { toListViewModel } from '../utils'; -import { DockerContainer } from '../types'; +import { ContainerListViewModel } from '../types'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { + withFiltersQueryParam, + withAgentTargetHeader, +} from '../../proxy/queries/utils'; import { Filters } from './types'; import { queryKeys } from './query-keys'; @@ -21,7 +22,7 @@ interface UseContainers { nodeName?: string; } -export function useContainers( +export function useContainers( environmentId: EnvironmentId, { autoRefreshRate, @@ -30,7 +31,7 @@ export function useContainers( ...params }: UseContainers & { autoRefreshRate?: number; - select?: (data: DockerContainer[]) => T; + select?: (data: ContainerListViewModel[]) => T; enabled?: boolean; } = {} ) { @@ -39,29 +40,29 @@ export function useContainers( () => getContainers(environmentId, params), { ...withGlobalError('Unable to retrieve containers'), - refetchInterval() { - return autoRefreshRate ?? false; - }, + refetchInterval: autoRefreshRate ?? false, select, enabled, } ); } +/** + * Fetch containers and transform to ContainerListViewModel + * @param environmentId + * @param param1 + * @returns ContainerListViewModel[] + */ export async function getContainers( environmentId: EnvironmentId, { all = true, filters, nodeName }: UseContainers = {} ) { try { const { data } = await axios.get( - urlBuilder(environmentId, undefined, 'json'), + buildDockerProxyUrl(environmentId, 'containers', 'json'), { - params: { all, filters: filters && JSON.stringify(filters) }, - headers: nodeName - ? { - [agentTargetHeader]: nodeName, - } - : undefined, + params: { all, ...withFiltersQueryParam(filters) }, + headers: { ...withAgentTargetHeader(nodeName) }, } ); return data.map((c) => toListViewModel(c)); diff --git a/app/react/docker/containers/queries/useCreateExecMutation.ts b/app/react/docker/containers/queries/useCreateExecMutation.ts new file mode 100644 index 000000000..44b044272 --- /dev/null +++ b/app/react/docker/containers/queries/useCreateExecMutation.ts @@ -0,0 +1,33 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { ContainerId } from '../types'; + +type ExecConfig = { + AttachStdin: boolean; // Attach to stdin of the exec command. + AttachStdout: boolean; // Attach to stdout of the exec command. + AttachStderr: boolean; // Attach to stderr of the exec command. + DetachKeys: string; // Override the key sequence for detaching a container. Format is a single character [a-Z] or ctrl- where is one of: a-z, @, ^, [, , or _. + Tty: boolean; // Allocate a pseudo-TTY. + Env: string[]; // A list of environment variables in the form ["VAR=value", ...]. + Cmd: string[]; // Command to run, as a string or array of strings. + Privileged: boolean; // Default: false - Runs the exec process with extended privileges. + User: string; // The user, and optionally, group to run the exec process inside the container. Format is one of: user, user:group, uid, or uid:gid. + WorkingDir: string; // The working directory for the exec process inside the container. +}; +export async function createExec( + environmentId: EnvironmentId, + id: ContainerId, + config: ExecConfig +) { + try { + const { data } = await axios.post<{ Id: string }>( + buildDockerProxyUrl(environmentId, 'containers', id, 'exec'), + config + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to create exec'); + } +} diff --git a/app/react/docker/containers/queries/useUpdateContainer.ts b/app/react/docker/containers/queries/useUpdateContainer.ts index 2d23cd361..fe2fb9ed0 100644 --- a/app/react/docker/containers/queries/useUpdateContainer.ts +++ b/app/react/docker/containers/queries/useUpdateContainer.ts @@ -1,10 +1,10 @@ import { Resources, RestartPolicy } from 'docker-types/generated/1.41'; -import { RawAxiosRequestHeaders } from 'axios'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { urlBuilder } from '../containers.service'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; /** * UpdateConfig holds the mutable attributes of a Container. @@ -12,27 +12,23 @@ import { urlBuilder } from '../containers.service'; */ interface UpdateConfig extends Resources { // Contains container's resources (cgroups, ulimits) - RestartPolicy?: RestartPolicy; } +/** + * Raw docker API proxy + */ export async function updateContainer( environmentId: EnvironmentId, containerId: string, config: UpdateConfig, { nodeName }: { nodeName?: string } = {} ) { - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; - } - try { await axios.post<{ Warnings: string[] }>( - urlBuilder(environmentId, containerId, 'update'), + buildDockerProxyUrl(environmentId, 'containers', containerId, 'update'), config, - { headers } + { headers: { ...withAgentTargetHeader(nodeName) } } ); } catch (err) { throw parseAxiosError(err, 'failed updating container'); diff --git a/app/react/docker/containers/types.ts b/app/react/docker/containers/types.ts index b13797932..88db551bc 100644 --- a/app/react/docker/containers/types.ts +++ b/app/react/docker/containers/types.ts @@ -24,6 +24,9 @@ export interface Port { export type ContainerId = string; +/** + * Computed fields from Container List Raw data + */ type DecoratedDockerContainer = { NodeName: string; ResourceControl?: ResourceControlViewModel; @@ -32,9 +35,23 @@ type DecoratedDockerContainer = { Status: ContainerStatus; Ports: Port[]; StatusText: string; - Image: string; Gpus: string; }; -export type DockerContainer = DecoratedDockerContainer & +/** + * Docker Container list ViewModel + * + * Alias AngularJS ContainerViewModel + * + * Raw details is ContainerDetailsJSON + */ +export type ContainerListViewModel = DecoratedDockerContainer & Omit; + +export type ContainerLogsParams = { + stdout?: boolean; + stderr?: boolean; + timestamps?: boolean; + since?: number; + tail?: number; +}; diff --git a/app/react/docker/containers/types/response.ts b/app/react/docker/containers/types/response.ts index 67f9e53c9..aeb39e65d 100644 --- a/app/react/docker/containers/types/response.ts +++ b/app/react/docker/containers/types/response.ts @@ -1,14 +1,11 @@ -import { - EndpointSettings, - MountPoint, - Port, -} from 'docker-types/generated/1.41'; +import { ContainerSummary } from 'docker-types/generated/1.41'; -import { PortainerMetadata } from '@/react/docker/types'; +import { PortainerResponse } from '@/react/docker/types'; +import { WithRequiredProperties } from '@/types'; -export interface SummaryNetworkSettings { - Networks: { [key: string]: EndpointSettings | undefined }; -} +export type SummaryNetworkSettings = NonNullable< + ContainerSummary['NetworkSettings'] +>; export interface Health { Status: 'healthy' | 'unhealthy' | 'starting'; @@ -16,24 +13,23 @@ export interface Health { Log: Array<{ Output: string }>; } -export interface DockerContainerResponse { - Id: string; - Names: string[]; - Image: string; - ImageID: string; - Command: string; - Created: number; - Ports: Port[]; - SizeRw?: number; - SizeRootFs?: number; - Labels: { [key: string]: string }; - State: string; - Status: string; - HostConfig: { - NetworkMode?: string; - }; - NetworkSettings?: SummaryNetworkSettings; - Mounts: MountPoint[]; - Portainer: PortainerMetadata; - IsPortainer: boolean; -} +/** + * Raw container list response item + */ +export type DockerContainerResponse = PortainerResponse< + WithRequiredProperties< + ContainerSummary, + | 'Id' + | 'Names' + | 'Image' + | 'ImageID' + | 'Command' + | 'Created' + | 'Ports' + | 'Labels' + | 'State' + | 'Status' + | 'HostConfig' + | 'Mounts' + > +>; diff --git a/app/react/docker/containers/utils.ts b/app/react/docker/containers/utils.ts index a1dca9974..e48201618 100644 --- a/app/react/docker/containers/utils.ts +++ b/app/react/docker/containers/utils.ts @@ -5,12 +5,17 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { useInfo } from '@/react/docker/proxy/queries/useInfo'; import { useEnvironment } from '@/react/portainer/environments/queries'; -import { DockerContainer, ContainerStatus } from './types'; +import { ContainerListViewModel, ContainerStatus } from './types'; import { DockerContainerResponse } from './types/response'; +/** + * Transform an item of the raw docker container list reponse to a container list view model + * @param response Raw docker container list reponse item + * @returns ContainerListViewModel + */ export function toListViewModel( response: DockerContainerResponse -): DockerContainer { +): ContainerListViewModel { const resourceControl = response.Portainer?.ResourceControl && new ResourceControlViewModel(response?.Portainer?.ResourceControl); diff --git a/app/react/docker/images/queries/build-url.ts b/app/react/docker/images/queries/build-url.ts deleted file mode 100644 index e9ad86c1a..000000000 --- a/app/react/docker/images/queries/build-url.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; -import { buildUrl as buildDockerUrl } from '@/react/docker/queries/utils/build-url'; -import { buildUrl as buildDockerProxyUrl } from '@/react/docker/proxy/queries/build-url'; - -export function buildUrl(environmentId: EnvironmentId) { - return buildDockerUrl(environmentId, 'images'); -} - -export function buildProxyUrl( - environmentId: EnvironmentId, - { id, action }: { id?: string; action?: string } = {} -) { - let dockerAction = ''; - if (id) { - dockerAction += `${id}`; - } - - if (action) { - dockerAction = dockerAction ? `${dockerAction}/${action}` : action; - } - - return buildDockerProxyUrl(environmentId, 'images', dockerAction); -} diff --git a/app/react/docker/images/queries/encodeRegistryCredentials.ts b/app/react/docker/images/queries/encodeRegistryCredentials.ts deleted file mode 100644 index 8903f0a5f..000000000 --- a/app/react/docker/images/queries/encodeRegistryCredentials.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Registry } from '@/react/portainer/registries/types/registry'; - -/** - * Encodes the registry credentials in base64 - * @param registryId - * @returns - */ -export function encodeRegistryCredentials(registryId: Registry['Id']) { - const credentials = { - registryId, - }; - - return window.btoa(JSON.stringify(credentials)); -} diff --git a/app/react/docker/images/queries/useBuildImageMutation.ts b/app/react/docker/images/queries/useBuildImageMutation.ts new file mode 100644 index 000000000..557fd6ad3 --- /dev/null +++ b/app/react/docker/images/queries/useBuildImageMutation.ts @@ -0,0 +1,285 @@ +import axios, { + jsonObjectsToArrayHandler, + parseAxiosError, +} from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { formatArrayQueryParamsForDockerAPI } from '../../proxy/queries/utils'; + +export async function buildImageFromUpload( + environmentId: EnvironmentId, + names: string[], + file: File, + path: string +) { + return buildImage( + environmentId, + { t: names, dockerfile: path }, + file, + file.type + ); +} + +export async function buildImageFromURL( + environmentId: EnvironmentId, + names: string[], + url: string, + path: string +) { + return buildImage( + environmentId, + { t: names, remote: url, dockerfile: path }, + {}, + 'application/x-tar' + ); +} + +export async function buildImageFromDockerfileContent( + environmentId: EnvironmentId, + names: string[], + content: string +) { + return buildImage( + environmentId, + { t: names }, + { content }, + 'application/json' + ); +} + +export async function buildImageFromDockerfileContentAndFiles( + environmentId: EnvironmentId, + names: string[], + content: string, + files: File[] +) { + const dockerfile = new Blob([content], { type: 'text/plain' }); + const uploadFiles = [dockerfile, ...files]; + + return buildImage( + environmentId, + { t: names }, + { file: uploadFiles }, + 'multipart/form-data' + ); +} + +/** + * Raw docker API proxy + * + * ----- + * + * See api/http/proxy/factory/docker/build.go for the rules (copied below) + * + * buildOperation inspects the "Content-Type" header to determine if it needs to alter the request. + * + * -- buildImageFromUpload() + * If the value of the header is empty, it means that a Dockerfile is posted via upload, the function + * will extract the file content from the request body, tar it, and rewrite the body. + * !! THIS IS ONLY TRUE WHEN THE UPLOADED DOCKERFILE FILE HAS NO EXTENSION (the generated file.type in the frontend will be empty) + * If the Dockerfile is named like Dockerfile.yaml or has an internal type, a non-empty Content-Type header will be generated + * + * -- buildImageFromDockerfileContent() + * If the value of the header contains "application/json", it means that the content of a Dockerfile is posted + * in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and + * rewrite the body of the request. + * + * -- buildImageFromUpload() + * -- buildImageFromURL() + * -- buildImageFromDockerfileContentAndFiles() + * In any other case, it will leave the request unaltered. + * + * ----- + * + * @param environmentId + * @param params + * @param payload + * @param contentType + */ +async function buildImage( + environmentId: EnvironmentId, + params: BuildImageQueryParams, + payload: unknown, + contentType: string +) { + try { + const { data } = await axios.post( + buildDockerProxyUrl(environmentId, 'build'), + payload, + { + headers: { 'Content-Type': contentType }, + params, + transformResponse: jsonObjectsToArrayHandler, + paramsSerializer: formatArrayQueryParamsForDockerAPI, + } + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to build image'); + } +} + +type BuildImageQueryParams = { + /** + * Path within the build context to the Dockerfile. + * This is ignored if remote is specified and points to an external Dockerfile. + * + * @default "Dockerfile" + */ + dockerfile?: string; + + /** + * A name and optional tag to apply to the image in the name:tag format. + * If you omit the tag the default latest value is assumed. + * You can provide several t parameters. + */ + t?: string[]; + + /** + * Extra hosts to add to /etc/hosts + */ + extrahost?: string; + + /** + * A Git repository URI or HTTP/HTTPS context URI. + * If the URI points to a single text file, the file’s contents are placed into a file called Dockerfile and the image is built from that file. + * If the URI points to a tarball, the file is downloaded by the daemon and the contents therein used as the context for the build. + * If the URI points to a tarball and the dockerfile parameter is also specified, there must be a file with the corresponding path inside the tarball. + */ + remote?: string; + + /** + * Suppress verbose build output. + * + * @default false + */ + q?: boolean; + + /** + * Do not use the cache when building the image. + * + * @default false + */ + nocache?: boolean; + + /** + * JSON array of images used for build cache resolution. + */ + cachefrom?: string[]; + + /** + * Attempt to pull the image even if an older image exists locally. + */ + pull?: string; + + /** + * Remove intermediate containers after a successful build. + * + * @default true + */ + rm?: boolean; + + /** + * Always remove intermediate containers, even upon failure. + * + * @default false + */ + forcerm?: boolean; + + /** + * Set memory limit for build. + */ + memory?: number; + + /** + * Total memory (memory + swap). + * + * Set as -1 to disable swap. + */ + memswap?: number; + + /** + * CPU shares (relative weight). + */ + cpushares?: number; + + /** + * CPUs in which to allow execution (e.g., 0-3, 0,1). + */ + cpusetcpus?: string; + + /** + * The length of a CPU period in microseconds. + */ + cpuperiod?: number; + + /** + * Microseconds of CPU time that the container can get in a CPU period. + */ + cpuquota?: number; + + /** + * JSON map of string pairs for build-time variables. Users pass these values at build-time. + * Docker uses the buildargs as the environment context for commands run via the Dockerfile RUN instruction, or for variable expansion in other Dockerfile instructions. + * This is not meant for passing secret values. + * For example, the build arg FOO=bar would become {"FOO":"bar"} in JSON. This would result in the query parameter buildargs={"FOO":"bar"}. + * Note that {"FOO":"bar"} should be URI component encoded. + * Read more about the buildargs instruction. + */ + buildargs?: string; + + /** + * Size of /dev/shm in bytes. The size must be greater than 0. If omitted the system uses 64MB. + */ + shmsize?: number; + + /** + * Squash the resulting images layers into a single layer. (Experimental release only.) + */ + squash?: boolean; + + /** + * Arbitrary key/value labels to set on the image, as a JSON map of string pairs. + */ + labels?: Record; + + /** + * Sets the networking mode for the run commands during build. Supported standard values are: bridge, host, none, and container:. + * Any other value is taken as a custom network's name or ID to which this container should connect to. + */ + networkmode?: string; + + /** + * Platform in the format os[/arch[/variant]] + * + * @default "" + */ + platform?: string; + + /** + * Target build stage + * + * @default "" + */ + target?: string; + + /** + * BuildKit output configuration + * + * @default "" + */ + outputs?: string; + + /** + * Version of the builder backend to use. + * + * @enum {('1' | '2')} + * + * @default '1' + * + * - 1 is the first generation classic (deprecated) builder in the Docker daemon (default) + * - 2 is BuildKit + */ + version?: string; +}; diff --git a/app/react/docker/images/queries/useImages.ts b/app/react/docker/images/queries/useImages.ts index fbde8ce1e..55bf83c63 100644 --- a/app/react/docker/images/queries/useImages.ts +++ b/app/react/docker/images/queries/useImages.ts @@ -3,7 +3,8 @@ import { useQuery } from 'react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { buildUrl } from './build-url'; +import { buildDockerUrl } from '../../queries/utils/buildDockerUrl'; + import { queryKeys } from './queryKeys'; export interface ImagesListResponse { @@ -20,6 +21,11 @@ export interface ImagesListResponse { used: boolean; } +/** + * Used in ImagesDatatable + * + * Query /api/docker/{envId}/images + */ export function useImages>( environmentId: EnvironmentId, withUsage = false, @@ -46,7 +52,7 @@ async function getImages( ) { try { const { data } = await axios.get>( - buildUrl(environmentId), + buildDockerUrl(environmentId, 'images'), { params: { withUsage } } ); return data; diff --git a/app/react/docker/images/queries/usePullImageMutation.ts b/app/react/docker/images/queries/usePullImageMutation.ts index e1d00fd01..f99f996a4 100644 --- a/app/react/docker/images/queries/usePullImageMutation.ts +++ b/app/react/docker/images/queries/usePullImageMutation.ts @@ -1,13 +1,13 @@ -import { RawAxiosRequestHeaders } from 'axios'; - import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { Registry } from '@/react/portainer/registries/types/registry'; import { buildImageFullURI } from '../utils'; - -import { encodeRegistryCredentials } from './encodeRegistryCredentials'; -import { buildProxyUrl } from './build-url'; +import { + withRegistryAuthHeader, + withAgentTargetHeader, +} from '../../proxy/queries/utils'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; interface PullImageOptions { environmentId: EnvironmentId; @@ -24,33 +24,27 @@ export async function pullImage({ nodeName, registry, }: PullImageOptions) { - const authenticationDetails = - registry && registry.Authentication - ? encodeRegistryCredentials(registry.Id) - : ''; - const imageURI = buildImageFullURI(image, registry); - const headers: RawAxiosRequestHeaders = { - 'X-Registry-Auth': authenticationDetails, - }; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; - } - try { - await axios.post(buildProxyUrl(environmentId, { action: 'create' }), null, { - params: { - fromImage: imageURI, - }, - headers, - }); + await axios.post( + buildDockerProxyUrl(environmentId, 'images', 'create'), + null, + { + params: { + fromImage: imageURI, + }, + headers: { + ...withRegistryAuthHeader(registry?.Id), + ...withAgentTargetHeader(nodeName), + }, + } + ); } catch (err) { if (ignoreErrors) { return; } - throw parseAxiosError(err as Error, 'Unable to pull image'); + throw parseAxiosError(err, 'Unable to pull image'); } } diff --git a/app/react/docker/images/queries/usePushImageMutation.ts b/app/react/docker/images/queries/usePushImageMutation.ts new file mode 100644 index 000000000..6408b00ef --- /dev/null +++ b/app/react/docker/images/queries/usePushImageMutation.ts @@ -0,0 +1,42 @@ +import axios, { + jsonObjectsToArrayHandler, + parseAxiosError, +} from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { Registry } from '@/react/portainer/registries/types/registry'; + +import { buildImageFullURI } from '../utils'; +import { withRegistryAuthHeader } from '../../proxy/queries/utils'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +interface PushImageOptions { + environmentId: EnvironmentId; + image: string; + registry?: Registry; +} + +export async function pushImage({ + environmentId, + image, + registry, +}: PushImageOptions) { + const imageURI = buildImageFullURI(image, registry); + + try { + const { data } = await axios.post( + buildDockerProxyUrl(environmentId, 'images', imageURI, 'push'), + null, + { + headers: { + ...withRegistryAuthHeader(registry?.Id), + }, + transformResponse: jsonObjectsToArrayHandler, + } + ); + if (data[data.length - 1].error) { + throw new Error(data[data.length - 1].error); + } + } catch (err) { + throw parseAxiosError(err, 'Unable to push image'); + } +} diff --git a/app/react/docker/images/types/response.ts b/app/react/docker/images/types/response.ts index 55c2a62c4..4ffc75476 100644 --- a/app/react/docker/images/types/response.ts +++ b/app/react/docker/images/types/response.ts @@ -1,6 +1,6 @@ -import { PortainerMetadata } from '../../types'; +import { PortainerResponse } from '../../types'; -export type DockerImageResponse = { +export type DockerImageResponse = PortainerResponse<{ Containers: number; Created: number; Id: string; @@ -10,5 +10,4 @@ export type DockerImageResponse = { RepoTags: string[]; SharedSize: number; Size: number; - Portainer?: PortainerMetadata; -}; +}>; diff --git a/app/react/docker/networks/.keep b/app/react/docker/networks/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/docker/networks/CreateView/.keep b/app/react/docker/networks/CreateView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/docker/networks/ItemView/.keep b/app/react/docker/networks/ItemView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/docker/networks/ItemView/ItemView.tsx b/app/react/docker/networks/ItemView/ItemView.tsx index bb753f819..d675f6508 100644 --- a/app/react/docker/networks/ItemView/ItemView.tsx +++ b/app/react/docker/networks/ItemView/ItemView.tsx @@ -4,16 +4,19 @@ import { useQueryClient } from 'react-query'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { AccessControlPanel } from '@/react/portainer/access-control/AccessControlPanel/AccessControlPanel'; import { ResourceControlType } from '@/react/portainer/access-control/types'; -import { DockerContainer } from '@/react/docker/containers/types'; +import { ContainerListViewModel } from '@/react/docker/containers/types'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; -import { useContainers } from '@/react/docker/containers/queries/containers'; +import { useContainers } from '@/react/docker/containers/queries/useContainers'; +import { notifySuccess } from '@/portainer/services/notifications'; import { confirmDelete } from '@@/modals/confirm'; import { PageHeader } from '@@/PageHeader'; -import { useNetwork, useDeleteNetwork } from '../queries'; +import { useDeleteNetwork } from '../queries/useDeleteNetworkMutation'; import { isSystemNetwork } from '../network.helper'; import { NetworkResponseContainers } from '../types'; +import { queryKeys } from '../queries/queryKeys'; +import { useNetwork } from '../queries/useNetwork'; import { NetworkDetailsTable } from './NetworkDetailsTable'; import { NetworkOptionsTable } from './NetworkOptionsTable'; @@ -28,7 +31,7 @@ export function ItemView() { } = useCurrentStateAndParams(); const environmentId = useEnvironmentId(); const networkQuery = useNetwork(environmentId, networkId, { nodeName }); - const deleteNetworkMutation = useDeleteNetwork(); + const deleteNetworkMutation = useDeleteNetwork(environmentId); const containersQuery = useContainers(environmentId, { filters: { network: [networkId], @@ -70,13 +73,9 @@ export function ItemView() { - queryClient.invalidateQueries([ - 'environments', - environmentId, - 'docker', - 'networks', - networkId, - ]) + queryClient.invalidateQueries( + queryKeys.item(environmentId, networkId) + ) } resourceControl={resourceControl} resourceType={ResourceControlType.Network} @@ -100,9 +99,10 @@ export function ItemView() { if (confirmed) { deleteNetworkMutation.mutate( - { environmentId, networkId, nodeName }, + { networkId, nodeName }, { onSuccess: () => { + notifySuccess('Network successfully removed', networkId); router.stateService.go('docker.networks'); }, } @@ -113,7 +113,7 @@ export function ItemView() { function filterContainersInNetwork( networkContainers?: NetworkResponseContainers, - containers: DockerContainer[] = [] + containers: ContainerListViewModel[] = [] ) { if (!networkContainers) { return []; diff --git a/app/react/docker/networks/ItemView/NetworkContainersTable.tsx b/app/react/docker/networks/ItemView/NetworkContainersTable.tsx index 680a9838b..b3ff00567 100644 --- a/app/react/docker/networks/ItemView/NetworkContainersTable.tsx +++ b/app/react/docker/networks/ItemView/NetworkContainersTable.tsx @@ -3,6 +3,7 @@ import { Server, Trash2 } from 'lucide-react'; import { Authorized } from '@/react/hooks/useUser'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { Icon } from '@/react/components/Icon'; +import { notifySuccess } from '@/portainer/services/notifications'; import { TableContainer, TableTitle } from '@@/datatables'; import { DetailsTable } from '@@/DetailsTable'; @@ -10,7 +11,7 @@ import { Button } from '@@/buttons'; import { Link } from '@@/Link'; import { NetworkContainer, NetworkId } from '../types'; -import { useDisconnectContainer } from '../queries'; +import { useDisconnectContainer } from '../queries/useDisconnectContainerMutation'; type Props = { networkContainers: NetworkContainer[]; @@ -33,7 +34,10 @@ export function NetworkContainersTable({ environmentId, networkId, }: Props) { - const disconnectContainer = useDisconnectContainer(); + const disconnectContainer = useDisconnectContainer({ + environmentId, + networkId, + }); if (networkContainers.length === 0) { return null; @@ -71,12 +75,19 @@ export function NetworkContainersTable({ color="dangerlight" onClick={() => { if (container.Id) { - disconnectContainer.mutate({ - containerId: container.Id, - environmentId, - networkId, - nodeName, - }); + disconnectContainer.mutate( + { + containerId: container.Id, + nodeName, + }, + { + onSuccess: () => + notifySuccess( + 'Container successfully disconnected', + networkId + ), + } + ); } }} > diff --git a/app/react/docker/networks/ListView/NestedNetwordsTable.tsx b/app/react/docker/networks/ListView/NestedNetworksTable.tsx similarity index 100% rename from app/react/docker/networks/ListView/NestedNetwordsTable.tsx rename to app/react/docker/networks/ListView/NestedNetworksTable.tsx diff --git a/app/react/docker/networks/ListView/NetworksDatatable.tsx b/app/react/docker/networks/ListView/NetworksDatatable.tsx index f4ad141d7..561e04e0b 100644 --- a/app/react/docker/networks/ListView/NetworksDatatable.tsx +++ b/app/react/docker/networks/ListView/NetworksDatatable.tsx @@ -21,7 +21,7 @@ import { useIsSwarm } from '../../proxy/queries/useInfo'; import { useColumns } from './columns'; import { DecoratedNetwork } from './types'; -import { NestedNetworksDatatable } from './NestedNetwordsTable'; +import { NestedNetworksDatatable } from './NestedNetworksTable'; const storageKey = 'docker.networks'; diff --git a/app/react/docker/networks/ListView/columns/index.ts b/app/react/docker/networks/ListView/columns/index.ts index b2be3b05c..52719d9c9 100644 --- a/app/react/docker/networks/ListView/columns/index.ts +++ b/app/react/docker/networks/ListView/columns/index.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import { useMemo } from 'react'; -import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { createOwnershipColumn } from '@/react/docker/components/datatables/createOwnershipColumn'; import { buildExpandColumn } from '@@/datatables/expand-column'; diff --git a/app/react/docker/networks/network.service.ts b/app/react/docker/networks/network.service.ts deleted file mode 100644 index 7ec454713..000000000 --- a/app/react/docker/networks/network.service.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ContainerId } from '@/react/docker/containers/types'; -import axios, { - agentTargetHeader, - parseAxiosError, -} from '@/portainer/services/axios'; -import { EnvironmentId } from '@/react/portainer/environments/types'; - -import { NetworkId, DockerNetwork } from './types'; - -type NetworkAction = 'connect' | 'disconnect' | 'create'; - -export async function getNetwork( - environmentId: EnvironmentId, - networkId: NetworkId, - { nodeName }: { nodeName?: string } = {} -) { - try { - const { data: network } = await axios.get( - buildUrl(environmentId, networkId), - nodeName - ? { - headers: { - [agentTargetHeader]: nodeName, - }, - } - : undefined - ); - return network; - } catch (e) { - throw parseAxiosError(e as Error, 'Unable to retrieve network details'); - } -} - -export async function deleteNetwork( - environmentId: EnvironmentId, - networkId: NetworkId, - { nodeName }: { nodeName?: string } = {} -) { - try { - await axios.delete( - buildUrl(environmentId, networkId), - nodeName - ? { - headers: { - [agentTargetHeader]: nodeName, - }, - } - : undefined - ); - return networkId; - } catch (e) { - throw parseAxiosError(e as Error, 'Unable to remove network'); - } -} - -export async function disconnectContainer( - environmentId: EnvironmentId, - networkId: NetworkId, - containerId: ContainerId, - nodeName?: string -) { - try { - await axios.post( - buildUrl(environmentId, networkId, 'disconnect'), - { - Container: containerId, - Force: false, - }, - nodeName - ? { - headers: { - [agentTargetHeader]: nodeName, - }, - } - : undefined - ); - return { networkId, environmentId }; - } catch (e) { - throw parseAxiosError( - e as Error, - 'Unable to disconnect container from network' - ); - } -} - -function buildUrl( - environmentId: EnvironmentId, - networkId?: NetworkId, - action?: NetworkAction -) { - let url = `endpoints/${environmentId}/docker/networks`; - - if (networkId) { - url += `/${networkId}`; - } - - if (action) { - url += `/${action}`; - } - - return url; -} diff --git a/app/react/docker/networks/queries.ts b/app/react/docker/networks/queries.ts deleted file mode 100644 index f0a30edb2..000000000 --- a/app/react/docker/networks/queries.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from 'react-query'; - -import { EnvironmentId } from '@/react/portainer/environments/types'; -import { - error as notifyError, - success as notifySuccess, -} from '@/portainer/services/notifications'; -import { ContainerId } from '@/react/docker/containers/types'; - -import { - getNetwork, - deleteNetwork, - disconnectContainer, -} from './network.service'; -import { NetworkId } from './types'; - -export function useNetwork( - environmentId: EnvironmentId, - networkId: NetworkId, - { nodeName }: { nodeName?: string } = {} -) { - return useQuery( - [ - 'environments', - environmentId, - 'docker', - 'networks', - networkId, - { nodeName }, - ], - () => getNetwork(environmentId, networkId, { nodeName }), - { - onError: (err) => { - notifyError('Failure', err as Error, 'Unable to get network'); - }, - } - ); -} - -export function useDeleteNetwork() { - return useMutation( - ({ - environmentId, - networkId, - nodeName, - }: { - environmentId: EnvironmentId; - networkId: NetworkId; - nodeName?: string; - }) => deleteNetwork(environmentId, networkId, { nodeName }), - { - onSuccess: (networkId) => { - notifySuccess('Network successfully removed', networkId); - }, - onError: (err) => { - notifyError('Failure', err as Error, 'Unable to remove network'); - }, - } - ); -} - -export function useDisconnectContainer() { - const client = useQueryClient(); - - return useMutation( - ({ - containerId, - environmentId, - networkId, - nodeName, - }: { - containerId: ContainerId; - environmentId: EnvironmentId; - networkId: NetworkId; - nodeName?: string; - }) => disconnectContainer(environmentId, networkId, containerId, nodeName), - { - onSuccess: ({ networkId, environmentId }) => { - notifySuccess('Container successfully disconnected', networkId); - return client.invalidateQueries([ - 'environments', - environmentId, - 'docker', - 'networks', - networkId, - ]); - }, - onError: (err) => { - notifyError( - 'Failure', - err as Error, - 'Unable to disconnect container from network' - ); - }, - } - ); -} diff --git a/app/react/docker/networks/queries/buildUrl.ts b/app/react/docker/networks/queries/buildUrl.ts deleted file mode 100644 index 38697791a..000000000 --- a/app/react/docker/networks/queries/buildUrl.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; - -import { buildUrl as buildDockerUrl } from '../../proxy/queries/build-url'; -import { NetworkId } from '../types'; - -export function buildUrl( - environmentId: EnvironmentId, - { id, action }: { id?: NetworkId; action?: string } = {} -) { - let baseUrl = 'networks'; - if (id) { - baseUrl += `/${id}`; - } - - if (action) { - baseUrl += `/${action}`; - } - - return buildDockerUrl(environmentId, baseUrl); -} diff --git a/app/react/docker/networks/queries/queryKeys.ts b/app/react/docker/networks/queries/queryKeys.ts index 20b912209..42ef0659a 100644 --- a/app/react/docker/networks/queries/queryKeys.ts +++ b/app/react/docker/networks/queries/queryKeys.ts @@ -1,6 +1,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { queryKeys as dockerQueryKeys } from '../../queries/utils'; +import { NetworkId } from '../types'; import { NetworksQuery } from './types'; @@ -9,4 +10,6 @@ export const queryKeys = { [...dockerQueryKeys.root(environmentId), 'networks'] as const, list: (environmentId: EnvironmentId, query: NetworksQuery) => [...queryKeys.base(environmentId), 'list', query] as const, + item: (environmentId: EnvironmentId, id: NetworkId) => + [...queryKeys.base(environmentId), id] as const, }; diff --git a/app/react/docker/networks/queries/useConnectContainer.ts b/app/react/docker/networks/queries/useConnectContainerMutation.ts similarity index 82% rename from app/react/docker/networks/queries/useConnectContainer.ts rename to app/react/docker/networks/queries/useConnectContainerMutation.ts index f9733effd..564e4e383 100644 --- a/app/react/docker/networks/queries/useConnectContainer.ts +++ b/app/react/docker/networks/queries/useConnectContainerMutation.ts @@ -1,5 +1,4 @@ import { EndpointSettings } from 'docker-types/generated/1.41'; -import { RawAxiosRequestHeaders } from 'axios'; import { useMutation, useQueryClient } from 'react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; @@ -11,8 +10,8 @@ import { } from '@/react-tools/react-query'; import { queryKeys as dockerQueryKeys } from '../../queries/utils'; - -import { buildUrl } from './buildUrl'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; interface ConnectContainerPayload { Container: string; @@ -40,6 +39,9 @@ interface ConnectContainer { nodeName?: string; } +/** + * Raw docker API proxy + */ export async function connectContainer({ environmentId, containerId, @@ -56,16 +58,11 @@ export async function connectContainer({ }; } - const headers: RawAxiosRequestHeaders = {}; - - if (nodeName) { - headers['X-PortainerAgent-Target'] = nodeName; - } - try { await axios.post( - buildUrl(environmentId, { id: networkId, action: 'connect' }), - payload + buildDockerProxyUrl(environmentId, 'networks', networkId, 'connect'), + payload, + { headers: { ...withAgentTargetHeader(nodeName) } } ); } catch (err) { throw parseAxiosError(err as Error, 'Unable to connect container'); diff --git a/app/react/docker/networks/queries/useCreateNetworkMutation.ts b/app/react/docker/networks/queries/useCreateNetworkMutation.ts new file mode 100644 index 000000000..011d17fc3 --- /dev/null +++ b/app/react/docker/networks/queries/useCreateNetworkMutation.ts @@ -0,0 +1,91 @@ +import { Network } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { + withAgentManagerOperationHeader, + withAgentTargetHeader, +} from '../../proxy/queries/utils'; + +type MacvlanConfigOnly = { + ConfigOnly: true; + Internal: false; + Attachable: false; + Options: { + parent: string; // parent network card + }; +}; + +type MacvlanConfigFrom = { + ConfigFrom: { + Network: string; + }; + Scope: 'swarm' | 'local'; +}; + +type NetworkConfigBase = { + Name: Required['Name']; + CheckDuplicate?: boolean; + Driver?: string; + Internal?: boolean; + Attachable?: boolean; + Ingress?: boolean; + IPAM?: Network['IPAM']; + EnableIPv6?: boolean; + Options?: Network['Options']; + Labels?: Network['Labels']; +}; + +/** + * This type definition of NetworkConfig doesnt enforce the usage of only one type of the union + * and not a mix of fields of the unionised types. + * e.g. the following is valid for TS while it is not for the Docker API + * + * const config: NetworkConfig = { + * Name: 'my-network', // shared + * ConfigOnly: true, // MacvlanConfigOnly + * Scope: 'swarm', // MacvlanConfigFrom + * } + * + */ +type NetworkConfig = + | NetworkConfigBase + | (NetworkConfigBase & MacvlanConfigOnly) + | (NetworkConfigBase & MacvlanConfigFrom); + +type CreateOptions = { + nodeName?: string; + agentManagerOperation?: boolean; +}; + +type CreateNetworkResponse = { + Id: string; + Warning: string; +}; + +/** + * Raw docker API proxy + */ +export async function createNetwork( + environmentId: EnvironmentId, + networkConfig: NetworkConfig, + { nodeName, agentManagerOperation }: CreateOptions = {} +) { + try { + const { data } = await axios.post( + buildDockerProxyUrl(environmentId, 'networks', 'create'), + networkConfig, + { + headers: { + ...withAgentTargetHeader(nodeName), + ...withAgentManagerOperationHeader(agentManagerOperation), + }, + } + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to create network'); + } +} diff --git a/app/react/docker/networks/queries/useDeleteNetworkMutation.ts b/app/react/docker/networks/queries/useDeleteNetworkMutation.ts new file mode 100644 index 000000000..ad4fc7eca --- /dev/null +++ b/app/react/docker/networks/queries/useDeleteNetworkMutation.ts @@ -0,0 +1,50 @@ +import { useMutation, useQueryClient } from 'react-query'; + +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; +import { NetworkId } from '../types'; + +import { queryKeys } from './queryKeys'; + +export function useDeleteNetwork(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + + return useMutation( + ({ networkId, nodeName }: { networkId: NetworkId; nodeName?: string }) => + deleteNetwork(environmentId, networkId, { nodeName }), + mutationOptions( + withInvalidate(queryClient, [queryKeys.base(environmentId)]), + withError('Unable to remove network') + ) + ); +} + +/** + * Raw docker API proxy + * @param environmentId + * @param networkId + * @returns + */ +export async function deleteNetwork( + environmentId: EnvironmentId, + networkId: NetworkId, + { nodeName }: { nodeName?: string } = {} +) { + try { + await axios.delete( + buildDockerProxyUrl(environmentId, 'networks', networkId), + { headers: { ...withAgentTargetHeader(nodeName) } } + ); + return networkId; + } catch (err) { + throw parseAxiosError(err, 'Unable to remove network'); + } +} diff --git a/app/react/docker/networks/queries/useDisconnectContainerMutation.ts b/app/react/docker/networks/queries/useDisconnectContainerMutation.ts new file mode 100644 index 000000000..6b41032f7 --- /dev/null +++ b/app/react/docker/networks/queries/useDisconnectContainerMutation.ts @@ -0,0 +1,68 @@ +import { useMutation, useQueryClient } from 'react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; +import { ContainerId } from '../../containers/types'; +import { NetworkId } from '../types'; + +import { queryKeys } from './queryKeys'; + +export function useDisconnectContainer({ + environmentId, + networkId, +}: { + environmentId: EnvironmentId; + networkId: NetworkId; +}) { + const client = useQueryClient(); + + return useMutation( + ({ + containerId, + nodeName, + }: { + containerId: ContainerId; + nodeName?: string; + }) => disconnectContainer(environmentId, networkId, containerId, nodeName), + mutationOptions( + withInvalidate(client, [queryKeys.item(environmentId, networkId)]), + withError('Unable to disconnect container from network') + ) + ); +} + +/** + * Raw docker API proxy + * @param environmentId + * @param networkId + * @param containerId + * @returns + */ +export async function disconnectContainer( + environmentId: EnvironmentId, + networkId: NetworkId, + containerId: ContainerId, + nodeName?: string +) { + try { + await axios.post( + buildDockerProxyUrl(environmentId, 'networks', networkId, 'disconnect'), + { + Container: containerId, + Force: false, + }, + { headers: { ...withAgentTargetHeader(nodeName) } } + ); + return { networkId, environmentId }; + } catch (err) { + throw parseAxiosError(err, 'Unable to disconnect container from network'); + } +} diff --git a/app/react/docker/networks/queries/useNetwork.ts b/app/react/docker/networks/queries/useNetwork.ts new file mode 100644 index 000000000..8f4504cbf --- /dev/null +++ b/app/react/docker/networks/queries/useNetwork.ts @@ -0,0 +1,52 @@ +import { useQuery } from 'react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { DockerNetwork, NetworkId } from '../types'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; + +import { queryKeys } from './queryKeys'; + +export function useNetwork( + environmentId: EnvironmentId, + networkId: NetworkId, + { nodeName }: { nodeName?: string } = {} +) { + return useQuery( + [...queryKeys.item(environmentId, networkId), { nodeName }], + () => getNetwork(environmentId, networkId, { nodeName }), + { + ...withGlobalError('Unable to get network'), + } + ); +} + +/** + * Raw docker API proxy + * @param environmentId + * @param networkId + * @param param2 + * @returns + */ +export async function getNetwork( + environmentId: EnvironmentId, + networkId: NetworkId, + { nodeName }: { nodeName?: string } = {} +) { + try { + const { data: network } = await axios.get( + buildDockerProxyUrl(environmentId, 'networks', networkId), + { + headers: { + ...withAgentTargetHeader(nodeName), + }, + } + ); + return network; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve network details'); + } +} diff --git a/app/react/docker/networks/queries/useNetworks.ts b/app/react/docker/networks/queries/useNetworks.ts index 64a42d255..2165871c4 100644 --- a/app/react/docker/networks/queries/useNetworks.ts +++ b/app/react/docker/networks/queries/useNetworks.ts @@ -3,8 +3,9 @@ import { useQuery } from 'react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { buildUrl } from '../../proxy/queries/build-url'; import { DockerNetwork } from '../types'; +import { withFiltersQueryParam } from '../../proxy/queries/utils'; +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; import { queryKeys } from './queryKeys'; import { NetworksQuery } from './types'; @@ -29,17 +30,18 @@ export function useNetworks>( ); } +/** + * Raw docker API proxy + */ export async function getNetworks( environmentId: EnvironmentId, { local, swarm, swarmAttachable, filters }: NetworksQuery ) { try { const { data } = await axios.get>( - buildUrl(environmentId, 'networks'), - filters && { - params: { - filters, - }, + buildDockerProxyUrl(environmentId, 'networks'), + { + params: { ...withFiltersQueryParam(filters) }, } ); diff --git a/app/react/docker/networks/types.ts b/app/react/docker/networks/types.ts index 8531da653..9f1cfc3b1 100644 --- a/app/react/docker/networks/types.ts +++ b/app/react/docker/networks/types.ts @@ -1,6 +1,5 @@ -import { PortainerMetadata } from '@/react/docker/types'; - -import { ContainerId } from '../containers/types'; +import { PortainerResponse } from '@/react/docker/types'; +import { ContainerId } from '@/react/docker/containers/types'; export type IPConfig = { Subnet: string; @@ -32,7 +31,7 @@ export type NetworkResponseContainers = Record< NetworkResponseContainer >; -export interface DockerNetwork { +export interface DockerNetwork extends PortainerResponse { Name: string; Id: NetworkId; Driver: string; @@ -44,7 +43,6 @@ export interface DockerNetwork { Driver: string; Options: IpamOptions; }; - Portainer?: PortainerMetadata; Options: NetworkOptions; Containers: NetworkResponseContainers; } diff --git a/app/react/docker/proxy/queries/build-url.ts b/app/react/docker/proxy/queries/build-url.ts deleted file mode 100644 index e4bb13900..000000000 --- a/app/react/docker/proxy/queries/build-url.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; - -export function buildUrl( - environmentId: EnvironmentId, - action: string, - subAction = '' -) { - let url = `/endpoints/${environmentId}/docker/${action}`; - - if (subAction) { - url += `/${subAction}`; - } - - return url; -} diff --git a/app/react/docker/proxy/queries/buildDockerProxyUrl.ts b/app/react/docker/proxy/queries/buildDockerProxyUrl.ts new file mode 100644 index 000000000..9f9218eb4 --- /dev/null +++ b/app/react/docker/proxy/queries/buildDockerProxyUrl.ts @@ -0,0 +1,50 @@ +import { compact } from 'lodash'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; + +/** + * Build docker proxy URL for Environment + * + * @param environmentId + * @param action + * @param subSegments Sub segments are only added to the URL when they are not `undefined`. + * @returns `/endpoints/{environmentId}/docker/{action}/{subSegments[0]}/{subSegments[1]}/...` + * + * @example + * // all calls return /endpoints/1/docker/action/sub1/sub2 + * buildDockerProxyUrl(1, 'action', 'sub1', 'sub2'); + * buildDockerProxyUrl(1, 'action', undefined, 'sub1', undefined, 'sub2'); + * + * @example + * function buildUrl(endpointId: EnvironmentId, id?: ServiceId, action?: string) { + * return buildDockerProxyUrl(endpointId, 'services', id, action); + *} + * + * // returns /endpoints/1/docker/services/ubx3r/update + * buildUrl(1, 'ubx3r', 'update') + * + * // returns /endpoints/1/docker/services/update + * buildUrl(1, undefined, 'update') + * + * // returns /endpoints/1/docker/services/ubx3r + * buildUrl(1, 'ubx3r') // = buildUrl(1, 'ubx3r', undefined) + * + * // returns /endpoints/1/docker/services + * buildUrl(1) // = buildUrl(1, undefined, undefined) + * + */ +export function buildDockerProxyUrl( + environmentId: EnvironmentId, + action: string, + ...subSegments: unknown[] +) { + let url = `/endpoints/${environmentId}/docker/${action}`; + + const joined = compact(subSegments).join('/'); + + if (joined) { + url += `/${joined}`; + } + + return url; +} diff --git a/app/react/docker/proxy/queries/images/useDownloadImages.ts b/app/react/docker/proxy/queries/images/useDownloadImages.ts new file mode 100644 index 000000000..cfcd4c06f --- /dev/null +++ b/app/react/docker/proxy/queries/images/useDownloadImages.ts @@ -0,0 +1,31 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; +import { formatArrayQueryParamsForDockerAPI } from '../utils'; + +/** + * Raw docker API proxy + */ +export async function downloadImages( + environmentId: EnvironmentId, + images: { tags: string[]; id: string }[] +) { + const names = images.map((image) => + image.tags[0] !== ':' ? image.tags[0] : image.id + ); + + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'images', 'get'), + { + params: { names }, + responseType: 'blob', + paramsSerializer: formatArrayQueryParamsForDockerAPI, + } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to download images'); + } +} diff --git a/app/react/docker/proxy/queries/images/useImage.ts b/app/react/docker/proxy/queries/images/useImage.ts new file mode 100644 index 000000000..23c012f19 --- /dev/null +++ b/app/react/docker/proxy/queries/images/useImage.ts @@ -0,0 +1,26 @@ +import { ImageInspect } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param id + * @returns + */ +export async function getImage( + environmentId: EnvironmentId, + id: Required +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'images', id, 'json') + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve image'); + } +} diff --git a/app/react/docker/proxy/queries/images/useImageHistory.ts b/app/react/docker/proxy/queries/images/useImageHistory.ts new file mode 100644 index 000000000..089a43b97 --- /dev/null +++ b/app/react/docker/proxy/queries/images/useImageHistory.ts @@ -0,0 +1,32 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +export type ImageLayer = { + Id: string; + Created: number; + CreatedBy: string; + Tags: string[]; + Size: number; + Comment: string; +}; + +/** + * Raw docker API proxy + * @param environmentId + * @returns + */ +export async function getImageHistory( + environmentId: EnvironmentId, + id: ImageLayer['Id'] +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'images', id, 'history') + ); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to retrieve image layers'); + } +} diff --git a/app/react/docker/proxy/queries/images/useImages.ts b/app/react/docker/proxy/queries/images/useImages.ts index 60ddd194e..815668e55 100644 --- a/app/react/docker/proxy/queries/images/useImages.ts +++ b/app/react/docker/proxy/queries/images/useImages.ts @@ -4,7 +4,7 @@ import { ImageSummary } from 'docker-types/generated/1.41'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { buildUrl } from '../build-url'; +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; import { queryKeys } from './queryKeys'; @@ -24,10 +24,15 @@ export function useImages( ); } -async function getImages(environmentId: EnvironmentId) { +/** + * Raw docker API proxy + * @param environmentId + * @returns + */ +export async function getImages(environmentId: EnvironmentId) { try { const { data } = await axios.get( - buildUrl(environmentId, 'images', 'json') + buildDockerProxyUrl(environmentId, 'images', 'json') ); return data; } catch (err) { diff --git a/app/react/docker/proxy/queries/images/useRemoveImageMutation.ts b/app/react/docker/proxy/queries/images/useRemoveImageMutation.ts new file mode 100644 index 000000000..d1cd859c0 --- /dev/null +++ b/app/react/docker/proxy/queries/images/useRemoveImageMutation.ts @@ -0,0 +1,28 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { ImageId, ImageName } from '@/docker/models/image'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param id + * @param force + * @returns + */ +export async function removeImage( + environmentId: EnvironmentId, + id: ImageId | ImageName, + force?: boolean +) { + try { + const { data } = await axios.delete( + buildDockerProxyUrl(environmentId, 'images', id), + { params: { force } } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to remove image'); + } +} diff --git a/app/react/docker/proxy/queries/images/useTagImageMutation.ts b/app/react/docker/proxy/queries/images/useTagImageMutation.ts new file mode 100644 index 000000000..95ca22517 --- /dev/null +++ b/app/react/docker/proxy/queries/images/useTagImageMutation.ts @@ -0,0 +1,29 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { ImageId, ImageName } from '@/docker/models/image'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param id + * @param repo string generated by `buildImageFullURIFromModel` or `buildImageFullURI` + * @returns + */ +export async function tagImage( + environmentId: EnvironmentId, + id: ImageId | ImageName, + repo: string +) { + try { + const { data } = await axios.post( + buildDockerProxyUrl(environmentId, 'images', id, 'tag'), + {}, + { params: { repo } } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to tag image'); + } +} diff --git a/app/react/docker/proxy/queries/images/useUploadImageMutation.ts b/app/react/docker/proxy/queries/images/useUploadImageMutation.ts new file mode 100644 index 000000000..5d3c6321c --- /dev/null +++ b/app/react/docker/proxy/queries/images/useUploadImageMutation.ts @@ -0,0 +1,26 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param file + * @returns + */ +export async function uploadImages(environmentId: EnvironmentId, file: File) { + try { + return await axios.post( + buildDockerProxyUrl(environmentId, 'images', 'load'), + file, + { + headers: { + 'Content-Type': file.type, // 'application/x-tar', + }, + } + ); + } catch (e) { + throw parseAxiosError(e, 'Unable to upload image'); + } +} diff --git a/app/react/docker/proxy/queries/nodes/build-url.ts b/app/react/docker/proxy/queries/nodes/build-url.ts deleted file mode 100644 index 2c422d18e..000000000 --- a/app/react/docker/proxy/queries/nodes/build-url.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; - -import { buildUrl as buildProxyUrl } from '../build-url'; - -export function buildUrl( - environmentId: EnvironmentId, - action?: string, - subAction = '' -) { - return buildProxyUrl( - environmentId, - 'nodes', - subAction ? `${action}/${subAction}` : action - ); -} diff --git a/app/react/docker/proxy/queries/nodes/useNode.ts b/app/react/docker/proxy/queries/nodes/useNode.ts new file mode 100644 index 000000000..670d1d495 --- /dev/null +++ b/app/react/docker/proxy/queries/nodes/useNode.ts @@ -0,0 +1,26 @@ +import { Node } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param id + * @returns + */ +export async function getNode( + environmentId: EnvironmentId, + id: NonNullable +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'nodes', id) + ); + return data; + } catch (error) { + throw parseAxiosError(error, 'Unable to retrieve node'); + } +} diff --git a/app/react/docker/proxy/queries/nodes/useNodes.ts b/app/react/docker/proxy/queries/nodes/useNodes.ts index cc52ffae1..474932178 100644 --- a/app/react/docker/proxy/queries/nodes/useNodes.ts +++ b/app/react/docker/proxy/queries/nodes/useNodes.ts @@ -4,16 +4,24 @@ import { useQuery } from 'react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { buildUrl } from './build-url'; +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + import { queryKeys } from './query-keys'; export function useNodes(environmentId: EnvironmentId) { return useQuery(queryKeys.base(environmentId), () => getNodes(environmentId)); } -async function getNodes(environmentId: EnvironmentId) { +/** + * Raw docker API proxy + * @param environmentId + * @returns + */ +export async function getNodes(environmentId: EnvironmentId) { try { - const { data } = await axios.get>(buildUrl(environmentId)); + const { data } = await axios.get>( + buildDockerProxyUrl(environmentId, 'nodes') + ); return data; } catch (error) { throw parseAxiosError(error, 'Unable to retrieve nodes'); diff --git a/app/react/docker/proxy/queries/nodes/useUpdateNodeMutation.ts b/app/react/docker/proxy/queries/nodes/useUpdateNodeMutation.ts new file mode 100644 index 000000000..069ae4a3b --- /dev/null +++ b/app/react/docker/proxy/queries/nodes/useUpdateNodeMutation.ts @@ -0,0 +1,31 @@ +import { Node, NodeSpec } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param id + * @param node + * @param version + */ +export async function updateNode( + environmentId: EnvironmentId, + id: NonNullable, + node: NodeSpec, + version: number +) { + try { + const { data } = await axios.post( + buildDockerProxyUrl(environmentId, 'nodes', id, 'update'), + node, + { params: { version } } + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to update node'); + } +} diff --git a/app/react/docker/proxy/queries/secrets/useCreateSecretMutation.ts b/app/react/docker/proxy/queries/secrets/useCreateSecretMutation.ts new file mode 100644 index 000000000..aae93fd45 --- /dev/null +++ b/app/react/docker/proxy/queries/secrets/useCreateSecretMutation.ts @@ -0,0 +1,21 @@ +import { SecretSpec } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +export async function createSecret( + environmentId: EnvironmentId, + secret: SecretSpec +) { + try { + const { data } = await axios.post( + buildDockerProxyUrl(environmentId, 'secrets', 'create'), + secret + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to create secret'); + } +} diff --git a/app/react/docker/proxy/queries/secrets/useRemoveSecretMutation.ts b/app/react/docker/proxy/queries/secrets/useRemoveSecretMutation.ts new file mode 100644 index 000000000..6d71f2685 --- /dev/null +++ b/app/react/docker/proxy/queries/secrets/useRemoveSecretMutation.ts @@ -0,0 +1,17 @@ +import { Secret } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +export async function removeSecret( + environmentId: EnvironmentId, + id: NonNullable +) { + try { + await axios.delete(buildDockerProxyUrl(environmentId, 'secrets', id)); + } catch (err) { + throw parseAxiosError(err, 'Unable to remove secret'); + } +} diff --git a/app/react/docker/proxy/queries/secrets/useSecret.ts b/app/react/docker/proxy/queries/secrets/useSecret.ts new file mode 100644 index 000000000..d7e9aa30b --- /dev/null +++ b/app/react/docker/proxy/queries/secrets/useSecret.ts @@ -0,0 +1,21 @@ +import { Secret } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { PortainerResponse } from '@/react/docker/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +export async function getSecret( + environmentId: EnvironmentId, + id: NonNullable +) { + try { + const { data } = await axios.get>( + buildDockerProxyUrl(environmentId, 'secrets', id) + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to retrieve secret'); + } +} diff --git a/app/react/docker/proxy/queries/secrets/useSecrets.ts b/app/react/docker/proxy/queries/secrets/useSecrets.ts new file mode 100644 index 000000000..a2ba1d5fa --- /dev/null +++ b/app/react/docker/proxy/queries/secrets/useSecrets.ts @@ -0,0 +1,15 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; + +export async function getSecrets(environmentId: EnvironmentId) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'secrets') + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to retrieve secrets'); + } +} diff --git a/app/react/docker/proxy/queries/tasks/useTasks.ts b/app/react/docker/proxy/queries/tasks/useTasks.ts new file mode 100644 index 000000000..099a5ed91 --- /dev/null +++ b/app/react/docker/proxy/queries/tasks/useTasks.ts @@ -0,0 +1,35 @@ +import { Task } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../buildDockerProxyUrl'; +import { withFiltersQueryParam } from '../utils'; + +type Filters = { + 'desired-state'?: 'running' | 'shutdown' | 'accepted'; + id?: Task['ID']; + label?: Task['Labels']; + name?: Task['Name']; + node?: Task['NodeID']; + service?: Task['ServiceID']; +}; + +export async function getTasks( + environmentId: EnvironmentId, + filters?: Filters +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'tasks'), + { + params: { + ...withFiltersQueryParam(filters), + }, + } + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to retrieve tasks'); + } +} diff --git a/app/react/docker/proxy/queries/useCommitContainerMutation.ts b/app/react/docker/proxy/queries/useCommitContainerMutation.ts new file mode 100644 index 000000000..9d13305c9 --- /dev/null +++ b/app/react/docker/proxy/queries/useCommitContainerMutation.ts @@ -0,0 +1,32 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from './buildDockerProxyUrl'; + +type CommitParams = { + container?: string; // The ID or name of the container to commit + repo?: string; // Repository name for the created image + tag?: string; // Tag name for the create image + comment?: string; // Commit message + author?: string; // Author of the image (e.g., John Hannibal Smith ) + pause?: boolean; // Default: true Whether to pause the container before committing + changes?: string; // Dockerfile instructions to apply while committing +}; + +export async function commitContainer( + environmentId: EnvironmentId, + params: CommitParams +) { + try { + const { data } = await axios.post<{ Id: string }>( + buildDockerProxyUrl(environmentId, 'commit'), + {}, + { + params, + } + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to commit container'); + } +} diff --git a/app/react/docker/proxy/queries/useEvents.ts b/app/react/docker/proxy/queries/useEvents.ts new file mode 100644 index 000000000..35e8d6cc9 --- /dev/null +++ b/app/react/docker/proxy/queries/useEvents.ts @@ -0,0 +1,30 @@ +import { EventMessage } from 'docker-types/generated/1.41'; + +import axios, { + jsonObjectsToArrayHandler, + parseAxiosError, +} from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from './buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param param1 + * @returns + */ +export async function getEvents( + environmentId: EnvironmentId, + { since, until }: { since: string; until: string } +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'events'), + { params: { since, until }, transformResponse: jsonObjectsToArrayHandler } + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to retrieve engine events'); + } +} diff --git a/app/react/docker/proxy/queries/useExecResizeTTYMutation.ts b/app/react/docker/proxy/queries/useExecResizeTTYMutation.ts new file mode 100644 index 000000000..6022ac271 --- /dev/null +++ b/app/react/docker/proxy/queries/useExecResizeTTYMutation.ts @@ -0,0 +1,30 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from './buildDockerProxyUrl'; + +/** + * Raw docker API proxy + * @param environmentId + * @param id exec instance id + */ +export async function resizeTTY( + environmentId: EnvironmentId, + id: string, + { width, height }: { width: number; height: number } +) { + try { + await axios.post( + buildDockerProxyUrl(environmentId, 'exec', id, 'resize'), + {}, + { + params: { + h: height, + w: width, + }, + } + ); + } catch (err) { + throw parseAxiosError(err, 'Unable to resize tty of exec'); + } +} diff --git a/app/react/docker/proxy/queries/useInfo.ts b/app/react/docker/proxy/queries/useInfo.ts index 1d7aba095..6d9b4a5a5 100644 --- a/app/react/docker/proxy/queries/useInfo.ts +++ b/app/react/docker/proxy/queries/useInfo.ts @@ -4,16 +4,16 @@ import { SystemInfo } from 'docker-types/generated/1.41'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { buildUrl } from './build-url'; +import { buildDockerProxyUrl } from './buildDockerProxyUrl'; export async function getInfo(environmentId: EnvironmentId) { try { const { data } = await axios.get( - buildUrl(environmentId, 'info') + buildDockerProxyUrl(environmentId, 'info') ); return data; } catch (err) { - throw parseAxiosError(err as Error, 'Unable to retrieve version'); + throw parseAxiosError(err, 'Unable to retrieve system info'); } } diff --git a/app/docker/services/ping.ts b/app/react/docker/proxy/queries/usePing.ts similarity index 59% rename from app/docker/services/ping.ts rename to app/react/docker/proxy/queries/usePing.ts index 5dad16870..3539cc259 100644 --- a/app/docker/services/ping.ts +++ b/app/react/docker/proxy/queries/usePing.ts @@ -1,10 +1,12 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; +import { buildDockerProxyUrl } from './buildDockerProxyUrl'; + export async function ping(environmentId: EnvironmentId) { try { - await axios.get(`/endpoints/${environmentId}/docker/_ping`); + await axios.get(buildDockerProxyUrl(environmentId, '_ping')); } catch (error) { - throw parseAxiosError(error as Error); + throw parseAxiosError(error); } } diff --git a/app/react/docker/proxy/queries/useServicePlugins.ts b/app/react/docker/proxy/queries/useServicePlugins.ts index 0117c0e83..912d6031a 100644 --- a/app/react/docker/proxy/queries/useServicePlugins.ts +++ b/app/react/docker/proxy/queries/useServicePlugins.ts @@ -10,13 +10,19 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { queryKeys } from '../../queries/utils/root'; -import { buildUrl } from './build-url'; +import { buildDockerProxyUrl } from './buildDockerProxyUrl'; import { useInfo } from './useInfo'; +const pluginTypeToVersionMap: { [k in keyof PluginsInfo]: string } = { + Volume: 'docker.volumedriver/1.0', + Network: 'docker.networkdriver/1.0', + Log: 'docker.logdriver/1.0', +}; + export async function getPlugins(environmentId: EnvironmentId) { try { const { data } = await axios.get>( - buildUrl(environmentId, 'plugins') + buildDockerProxyUrl(environmentId, 'plugins') ); return data; } catch (e) { @@ -38,77 +44,74 @@ function usePlugins( export function useServicePlugins( environmentId: EnvironmentId, systemOnly: boolean, - pluginType: keyof PluginsInfo, - pluginVersion: string + pluginType: keyof PluginsInfo ) { const systemPluginsQuery = useInfo(environmentId, (info) => info.Plugins); const pluginsQuery = usePlugins(environmentId, { enabled: !systemOnly }); return { - data: aggregateData(), + data: aggregateData( + systemPluginsQuery.data, + pluginsQuery.data, + systemOnly, + pluginType + ), isLoading: systemPluginsQuery.isLoading || pluginsQuery.isLoading, }; +} - function aggregateData() { - if (!systemPluginsQuery.data) { - return null; - } - - const systemPlugins = systemPluginsQuery.data[pluginType] || []; - - if (systemOnly) { - return systemPlugins; - } - - const plugins = - pluginsQuery.data - ?.filter( - (plugin) => - plugin.Enabled && - // docker has an error in their types, so we need to cast to unknown first - // see https://docs.docker.com/engine/api/v1.41/#tag/Plugin/operation/PluginList - plugin.Config.Interface.Types.includes( - pluginVersion as unknown as PluginInterfaceType - ) - ) - .map((plugin) => plugin.Name) || []; - - return [...systemPlugins, ...plugins]; +/** + * @private Exported only for AngularJS `PluginService` factory `app/docker/services/pluginService.js` + */ +export function aggregateData( + systemPluginsData: PluginsInfo | undefined, + pluginsData: Plugin[] | undefined, + systemOnly: boolean, + pluginType: keyof PluginsInfo +) { + if (!systemPluginsData) { + return null; } + + const systemPlugins = systemPluginsData[pluginType] || []; + + if (systemOnly) { + return systemPlugins; + } + + const plugins = + pluginsData + ?.filter( + (plugin) => + plugin.Enabled && + // docker has an error in their types, so we need to cast to unknown first + // see https://docs.docker.com/engine/api/v1.41/#tag/Plugin/operation/PluginList + plugin.Config.Interface.Types.includes( + pluginTypeToVersionMap[pluginType] as unknown as PluginInterfaceType + ) + ) + .map((plugin) => plugin.Name) || []; + + return [...systemPlugins, ...plugins]; } export function useLoggingPlugins( environmentId: EnvironmentId, systemOnly: boolean ) { - return useServicePlugins( - environmentId, - systemOnly, - 'Log', - 'docker.logdriver/1.0' - ); + return useServicePlugins(environmentId, systemOnly, 'Log'); } export function useVolumePlugins( environmentId: EnvironmentId, systemOnly: boolean ) { - return useServicePlugins( - environmentId, - systemOnly, - 'Volume', - 'docker.volumedriver/1.0' - ); + return useServicePlugins(environmentId, systemOnly, 'Volume'); } export function useNetworkPlugins( environmentId: EnvironmentId, systemOnly: boolean ) { - return useServicePlugins( - environmentId, - systemOnly, - 'Network', - 'docker.networkdriver/1.0' - ); + return useServicePlugins(environmentId, systemOnly, 'Network'); } diff --git a/app/react/docker/proxy/queries/useSwarm.ts b/app/react/docker/proxy/queries/useSwarm.ts new file mode 100644 index 000000000..effda69da --- /dev/null +++ b/app/react/docker/proxy/queries/useSwarm.ts @@ -0,0 +1,40 @@ +import { useQuery } from 'react-query'; +import { Swarm } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys } from './query-keys'; +import { useIsSwarm } from './useInfo'; +import { buildDockerProxyUrl } from './buildDockerProxyUrl'; + +export function useSwarm( + environmentId: EnvironmentId, + { select }: { select?(value: Swarm): T } = {} +) { + const isSwarm = useIsSwarm(environmentId); + + return useQuery({ + queryKey: [...queryKeys.base(environmentId), 'swarm'] as const, + queryFn: () => getSwarm(environmentId), + select, + enabled: isSwarm, + }); +} + +export async function getSwarm(environmentId: EnvironmentId) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'swarm') + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to retrieve swarm information'); + } +} + +export function useSwarmId(environmentId: EnvironmentId) { + return useSwarm(environmentId, { + select: (swarm) => swarm.ID, + }); +} diff --git a/app/react/docker/proxy/queries/useVersion.ts b/app/react/docker/proxy/queries/useVersion.ts index e2912b4c1..99b03760a 100644 --- a/app/react/docker/proxy/queries/useVersion.ts +++ b/app/react/docker/proxy/queries/useVersion.ts @@ -4,12 +4,12 @@ import { SystemVersion } from 'docker-types/generated/1.41'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { buildUrl } from './build-url'; +import { buildDockerProxyUrl } from './buildDockerProxyUrl'; export async function getVersion(environmentId: EnvironmentId) { try { const { data } = await axios.get( - buildUrl(environmentId, 'version') + buildDockerProxyUrl(environmentId, 'version') ); return data; } catch (err) { diff --git a/app/react/docker/proxy/queries/utils.ts b/app/react/docker/proxy/queries/utils.ts new file mode 100644 index 000000000..fc097833f --- /dev/null +++ b/app/react/docker/proxy/queries/utils.ts @@ -0,0 +1,67 @@ +import { agentTargetHeader } from '@/portainer/services/axios'; +import { RegistryId } from '@/react/portainer/registries/types/registry'; + +/** + * Generates the `filters` query param entry for docker API list actions + * + * @param filters map[string][]string + * @returns `{ filters: filters && JSON.stringify(filters) }` + */ +export function withFiltersQueryParam(filters?: T) { + return { filters: filters && JSON.stringify(filters) }; +} + +/** + * Encodes the registry credentials in base64 + * @param registryId + * @returns + */ +function encodeRegistryCredentials(registryId: RegistryId) { + return window.btoa(JSON.stringify({ registryId })); +} + +/** + * Generates the `X-Registry-Auth` header for docker API + * @param registryId The registry Id to use + * @returns + */ +export function withRegistryAuthHeader(registryId?: RegistryId) { + return registryId !== undefined + ? { 'X-Registry-Auth': encodeRegistryCredentials(registryId) } + : {}; +} + +/** + * Generates the `X-PortainerAgent-Target` header + * @param nodeName The node name to target + * @returns + */ +export function withAgentTargetHeader(nodeName?: string) { + return nodeName ? { [agentTargetHeader]: nodeName } : {}; +} + +/** + * Generates the `'X-PortainerAgent-ManagerOperation'` header + * @param managerOperation + * @returns + */ +export function withAgentManagerOperationHeader(managerOperation?: boolean) { + return managerOperation ? { 'X-PortainerAgent-ManagerOperation': '1' } : {}; +} + +/** + * The Docker API expects array query params as `param = value_1 & param = value_2` + * axios default serializer generates `names[] = value_1 & names[] = value_2` + * which are ignored by the Docker API + * @param params + * @returns the concatenated string of query params with Docker expected format + */ +export function formatArrayQueryParamsForDockerAPI( + params: Record +) { + return Object.entries(params) + .flatMap(([param, value]) => + (Array.isArray(value) ? value : [value]).map((v) => `${param}=${v}`) + ) + .join('&'); +} diff --git a/app/react/docker/queries/utils/build-url.ts b/app/react/docker/queries/utils/build-url.ts deleted file mode 100644 index 5f49a4e42..000000000 --- a/app/react/docker/queries/utils/build-url.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; - -export function buildUrl(environmentId: EnvironmentId, path: string) { - return `/docker/${environmentId}/${path}`; -} diff --git a/app/react/docker/queries/utils/buildDockerUrl.ts b/app/react/docker/queries/utils/buildDockerUrl.ts new file mode 100644 index 000000000..0c94c03db --- /dev/null +++ b/app/react/docker/queries/utils/buildDockerUrl.ts @@ -0,0 +1,50 @@ +import { compact } from 'lodash'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; + +/** + * Build docker URL for Environment + * + * @param environmentId + * @param action + * @param subSegments Sub segments are only added to the URL when they are not `undefined`. + * @returns `/docker/{environmentId}/{action}/{subSegments[0]}/{subSegments[1]}/...` + * + * @example + * // all calls return /docker/1/action/sub1/sub2 + * buildDockerUrl(1, 'action', 'sub1', 'sub2'); + * buildDockerUrl(1, 'action', undefined, 'sub1', undefined, 'sub2'); + * + * @example + * function buildUrl(endpointId: EnvironmentId, id?: ServiceId, action?: string) { + * return buildDockerUrl(endpointId, 'services', id, action); + *} + * + * // returns /docker/1/services/ubx3r/update + * buildUrl(1, 'ubx3r', 'update') + * + * // returns /docker/1/services/update + * buildUrl(1, undefined, 'update') + * + * // returns /docker/1/services/ubx3r + * buildUrl(1, 'ubx3r') // = buildUrl(1, 'ubx3r', undefined) + * + * // returns /docker/1/services + * buildUrl(1) // = buildUrl(1, undefined, undefined) + * + */ +export function buildDockerUrl( + environmentId: EnvironmentId, + action: string, + ...subSegments: unknown[] +) { + let url = `/docker/${environmentId}/${action}`; + + const joined = compact(subSegments).join('/'); + + if (joined) { + url += `/${joined}`; + } + + return url; +} diff --git a/app/react/docker/queries/utils/container.ts b/app/react/docker/queries/utils/container.ts index fabba745e..2fb0484d4 100644 --- a/app/react/docker/queries/utils/container.ts +++ b/app/react/docker/queries/utils/container.ts @@ -1,4 +1,4 @@ -import { DockerContainer } from '@/react/docker/containers/types'; +import { ContainerListViewModel } from '@/react/docker/containers/types'; import { EdgeStack } from '@/react/edge/edge-stacks/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; @@ -18,13 +18,13 @@ export const queryKeys = { ) => [...queryKeys.containers(environmentId), params] as const, container: ( environmentId: EnvironmentId, - containerId: DockerContainer['Id'] + containerId: ContainerListViewModel['Id'] ) => [...queryKeys.containers(environmentId), containerId] as const, }; export function buildDockerSnapshotContainersUrl( environmentId: EnvironmentId, - containerId?: DockerContainer['Id'] + containerId?: ContainerListViewModel['Id'] ) { let url = `${buildDockerSnapshotUrl(environmentId)}/containers`; diff --git a/app/react/docker/queries/utils/root.ts b/app/react/docker/queries/utils/root.ts index 89b80b8c7..d47a94a96 100644 --- a/app/react/docker/queries/utils/root.ts +++ b/app/react/docker/queries/utils/root.ts @@ -1,5 +1,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; +import { buildDockerUrl } from './buildDockerUrl'; + export const queryKeys = { root: (environmentId: EnvironmentId) => ['docker', environmentId] as const, snapshot: (environmentId: EnvironmentId) => @@ -10,10 +12,6 @@ export const queryKeys = { [...queryKeys.root(environmentId), 'plugins'] as const, }; -export function buildDockerUrl(environmentId: EnvironmentId) { - return `/docker/${environmentId}`; -} - export function buildDockerSnapshotUrl(environmentId: EnvironmentId) { - return `${buildDockerUrl(environmentId)}/snapshot`; + return buildDockerUrl(environmentId, 'snapshot'); } diff --git a/app/react/docker/secrets/ListView/SecretsDatatable.tsx b/app/react/docker/secrets/ListView/SecretsDatatable.tsx index 81343a4e1..024d941d4 100644 --- a/app/react/docker/secrets/ListView/SecretsDatatable.tsx +++ b/app/react/docker/secrets/ListView/SecretsDatatable.tsx @@ -19,7 +19,7 @@ import { Button } from '@@/buttons'; import { Link } from '@@/Link'; import { useRepeater } from '@@/datatables/useRepeater'; -import { createOwnershipColumn } from '../../components/datatable/createOwnershipColumn'; +import { createOwnershipColumn } from '../../components/datatables/createOwnershipColumn'; const columnHelper = createColumnHelper(); diff --git a/app/react/docker/services/ItemView/TasksDatatable/types.ts b/app/react/docker/services/ItemView/TasksDatatable/types.ts index fdff024a9..e58c37dc2 100644 --- a/app/react/docker/services/ItemView/TasksDatatable/types.ts +++ b/app/react/docker/services/ItemView/TasksDatatable/types.ts @@ -1,6 +1,6 @@ import { TaskViewModel } from '@/docker/models/task'; -import { DockerContainer } from '@/react/docker/containers/types'; +import { ContainerListViewModel } from '@/react/docker/containers/types'; export type DecoratedTask = TaskViewModel & { - Container?: DockerContainer; + Container?: ContainerListViewModel; }; diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/image.tsx b/app/react/docker/services/ListView/ServicesDatatable/columns/image.tsx index ff71a6669..4d5c75fd1 100644 --- a/app/react/docker/services/ListView/ServicesDatatable/columns/image.tsx +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/image.tsx @@ -5,7 +5,7 @@ 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 { ImageUpToDateTooltip } from '@/react/docker/components/datatables/TableColumnHeaderImageUpToDate'; import { columnHelper } from './helper'; diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/index.ts b/app/react/docker/services/ListView/ServicesDatatable/columns/index.ts index 599d71026..0cbc85396 100644 --- a/app/react/docker/services/ListView/ServicesDatatable/columns/index.ts +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/index.ts @@ -3,7 +3,7 @@ import _ from 'lodash'; import { ServiceViewModel } from '@/docker/models/service'; import { isoDate } from '@/portainer/filters/filters'; -import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { createOwnershipColumn } from '@/react/docker/components/datatables/createOwnershipColumn'; import { buildNameColumn } from '@@/datatables/buildNameColumn'; import { buildExpandColumn } from '@@/datatables/expand-column'; diff --git a/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleForm.tsx b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleForm.tsx index 716903838..8b76d2717 100644 --- a/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleForm.tsx +++ b/app/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleForm.tsx @@ -18,7 +18,7 @@ export function ScaleForm({ service: ServiceViewModel; }) { const environmentId = useEnvironmentId(); - const mutation = useUpdateServiceMutation(); + const mutation = useUpdateServiceMutation(environmentId); const router = useRouter(); return ( [...dockerQueryKeys.root(environmentId), 'services'] as const, + filters: (environmentId: EnvironmentId, filters: Filters = {}) => + [...queryKeys.list(environmentId), filters] as const, + service: (environmentId: EnvironmentId, id: string) => [...queryKeys.list(environmentId), id] as const, }; diff --git a/app/react/docker/services/queries/useCreateServiceMutation.ts b/app/react/docker/services/queries/useCreateServiceMutation.ts new file mode 100644 index 000000000..d1fc27768 --- /dev/null +++ b/app/react/docker/services/queries/useCreateServiceMutation.ts @@ -0,0 +1,54 @@ +import { Service } 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, + withInvalidate, +} from '@/react-tools/react-query'; + +import { ServiceUpdateConfig } from '../types'; +import { withRegistryAuthHeader } from '../../proxy/queries/utils'; + +import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; + +export function useCreateServiceMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + + return useMutation( + createService, + mutationOptions( + withInvalidate(queryClient, [queryKeys.list(environmentId)]), + withError('Unable to create service') + ) + ); +} + +export async function createService({ + environmentId, + config, + registryId, +}: { + environmentId: EnvironmentId; + config: ServiceUpdateConfig; + registryId?: number; +}) { + try { + const { data } = await axios.post( + buildUrl(environmentId, 'create'), + config, + { + headers: { + version: '1.29', // https://github.com/orgs/portainer/discussions/9407#discussioncomment-6559219 + ...withRegistryAuthHeader(registryId), + }, + } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to create service'); + } +} diff --git a/app/react/docker/services/queries/useService.ts b/app/react/docker/services/queries/useService.ts new file mode 100644 index 000000000..5174aebaa --- /dev/null +++ b/app/react/docker/services/queries/useService.ts @@ -0,0 +1,35 @@ +import { useQuery } from 'react-query'; +import { Service } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withGlobalError } from '@/react-tools/react-query'; +import { ServiceId } from '@/react/docker/services/types'; +import { queryKeys } from '@/react/docker/services/queries/query-keys'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { buildUrl } from '@/react/docker/services/queries/build-url'; + +export function useService(environmentId: EnvironmentId, serviceId: ServiceId) { + return useQuery( + queryKeys.service(environmentId, serviceId), + () => getService(environmentId, serviceId), + { + enabled: !!serviceId, + ...withGlobalError('Unable to retrieve service'), + } + ); +} + +export async function getService( + environmentId: EnvironmentId, + serviceId: ServiceId +) { + try { + const { data } = await axios.get( + buildUrl(environmentId, serviceId) + ); + + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to get service'); + } +} diff --git a/app/react/docker/services/queries/useServiceLogs.ts b/app/react/docker/services/queries/useServiceLogs.ts new file mode 100644 index 000000000..2950a2d2a --- /dev/null +++ b/app/react/docker/services/queries/useServiceLogs.ts @@ -0,0 +1,33 @@ +import _ from 'lodash'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { buildUrl } from '@/react/docker/services/queries/build-url'; +import { ServiceId } from '@/react/docker/services/types'; + +type ServiceLogsParams = { + stdout?: boolean; + stderr?: boolean; + timestamps?: boolean; + since?: number; + tail?: number; +}; + +export async function getServiceLogs( + environmentId: EnvironmentId, + serviceId: ServiceId, + params?: ServiceLogsParams +): Promise { + try { + const { data } = await axios.get( + buildUrl(environmentId, serviceId, 'logs'), + { + params: _.pickBy(params), + } + ); + + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to get service logs'); + } +} diff --git a/app/react/docker/services/queries/useServices.ts b/app/react/docker/services/queries/useServices.ts new file mode 100644 index 000000000..2ce6575d4 --- /dev/null +++ b/app/react/docker/services/queries/useServices.ts @@ -0,0 +1,36 @@ +import { useQuery } from 'react-query'; +import { Service } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withGlobalError } from '@/react-tools/react-query'; +import { queryKeys } from '@/react/docker/services/queries/query-keys'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { buildUrl } from '@/react/docker/services/queries/build-url'; + +import { Filters } from '../types'; +import { withFiltersQueryParam } from '../../proxy/queries/utils'; + +export function useServices(environmentId: EnvironmentId, filters?: Filters) { + return useQuery( + queryKeys.filters(environmentId, filters), + () => getServices(environmentId, filters), + { + ...withGlobalError('Unable to retrieve services'), + } + ); +} + +export async function getServices( + environmentId: EnvironmentId, + filters?: Filters +) { + try { + const { data } = await axios.get(buildUrl(environmentId), { + params: withFiltersQueryParam(filters), + }); + + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to get services'); + } +} diff --git a/app/react/docker/services/queries/useUpdateServiceMutation.ts b/app/react/docker/services/queries/useUpdateServiceMutation.ts index da98e8b1f..43e78b37a 100644 --- a/app/react/docker/services/queries/useUpdateServiceMutation.ts +++ b/app/react/docker/services/queries/useUpdateServiceMutation.ts @@ -1,36 +1,33 @@ -import { - TaskSpec, - ServiceSpec, - ServiceUpdateResponse, -} from 'docker-types/generated/1.41'; +import { 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 { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; -import { encodeRegistryCredentials } from '../../images/queries/encodeRegistryCredentials'; -import { urlBuilder } from '../axios/urlBuilder'; +import { ServiceUpdateConfig } from '../types'; +import { withRegistryAuthHeader } from '../../proxy/queries/utils'; +import { buildUrl } from './build-url'; import { queryKeys } from './query-keys'; -export function useUpdateServiceMutation() { +export function useUpdateServiceMutation(environmentId: EnvironmentId) { const queryClient = useQueryClient(); return useMutation( updateService, mutationOptions( - { - onSuccess(data, { environmentId }) { - return queryClient.invalidateQueries(queryKeys.list(environmentId)); - }, - }, + withInvalidate(queryClient, [queryKeys.list(environmentId)]), withError('Unable to update service') ) ); } -async function updateService({ +export async function updateService({ environmentId, serviceId, config, @@ -47,20 +44,17 @@ async function updateService({ }) { try { const { data } = await axios.post( - urlBuilder(environmentId, serviceId, 'update'), + buildUrl(environmentId, serviceId, 'update'), config, { params: { rollback, version, }, - ...(registryId - ? { - headers: { - 'X-Registry-Id': encodeRegistryCredentials(registryId), - }, - } - : {}), + headers: { + version: '1.29', // https://github.com/orgs/portainer/discussions/9407#discussioncomment-6559219 + ...withRegistryAuthHeader(registryId), + }, } ); return data; @@ -68,13 +62,3 @@ async function updateService({ 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/react/docker/services/types.ts b/app/react/docker/services/types.ts index fc37f13b0..1474959e3 100644 --- a/app/react/docker/services/types.ts +++ b/app/react/docker/services/types.ts @@ -1,16 +1,20 @@ +import { ServiceSpec, TaskSpec } from 'docker-types/generated/1.41'; + export type ServiceId = string; -export interface DockerServiceResponse { - ID: string; - Spec: { - Name: string; - }; -} - -export type ServiceLogsParams = { - stdout?: boolean; - stderr?: boolean; - timestamps?: boolean; - since?: number; - tail?: number; +export type Filters = { + id?: ServiceId[]; + label?: string[]; + mode?: ['replicated' | 'global']; + name?: string[]; }; + +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/react/docker/snapshots/types/index.ts b/app/react/docker/snapshots/types/index.ts index d7fcdc1a0..850e305f9 100644 --- a/app/react/docker/snapshots/types/index.ts +++ b/app/react/docker/snapshots/types/index.ts @@ -1,7 +1,7 @@ -import { DockerContainer } from '@/react/docker/containers/types'; +import { ContainerListViewModel } from '@/react/docker/containers/types'; -export type DockerContainerSnapshot = DockerContainer & { - Env?: string[]; +export type DockerContainerSnapshot = ContainerListViewModel & { + Env: string[]; }; export type DockerSnapshotRaw = { diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/image-notification.tsx b/app/react/docker/stacks/ListView/StacksDatatable/columns/image-notification.tsx index fca1fcb9a..4d8dcb217 100644 --- a/app/react/docker/stacks/ListView/StacksDatatable/columns/image-notification.tsx +++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/image-notification.tsx @@ -1,6 +1,6 @@ import { CellContext } from '@tanstack/react-table'; -import { ImageUpToDateTooltip } from '@/react/docker/components/datatable/TableColumnHeaderImageUpToDate'; +import { ImageUpToDateTooltip } from '@/react/docker/components/datatables/TableColumnHeaderImageUpToDate'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { isRegularStack } from '@/react/docker/stacks/view-models/utils'; diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/index.ts b/app/react/docker/stacks/ListView/StacksDatatable/columns/index.ts index 63bcd5ec4..78178addc 100644 --- a/app/react/docker/stacks/ListView/StacksDatatable/columns/index.ts +++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/index.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import { StackType } from '@/react/common/stacks/types'; import { isoDateFromTimestamp } from '@/portainer/filters/filters'; -import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { createOwnershipColumn } from '@/react/docker/components/datatables/createOwnershipColumn'; import { DecoratedStack } from '../types'; diff --git a/app/react/docker/stacks/view-models/external-stack.ts b/app/react/docker/stacks/view-models/external-stack.ts index 120dbe8ed..5302eec53 100644 --- a/app/react/docker/stacks/view-models/external-stack.ts +++ b/app/react/docker/stacks/view-models/external-stack.ts @@ -3,7 +3,7 @@ import _ from 'lodash'; import { StackType } from '@/react/common/stacks/types'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; -import { IResource } from '../../components/datatable/createOwnershipColumn'; +import { IResource } from '../../components/datatables/createOwnershipColumn'; export class ExternalStackViewModel implements IResource { Id: string; diff --git a/app/react/docker/stacks/view-models/stack.ts b/app/react/docker/stacks/view-models/stack.ts index 63770900d..83c018e77 100644 --- a/app/react/docker/stacks/view-models/stack.ts +++ b/app/react/docker/stacks/view-models/stack.ts @@ -6,7 +6,7 @@ import { RepoConfigResponse, } from '@/react/portainer/gitops/types'; -import { IResource } from '../../components/datatable/createOwnershipColumn'; +import { IResource } from '../../components/datatables/createOwnershipColumn'; export class StackViewModel implements IResource { Id: number; diff --git a/app/react/docker/tasks/queries/useTask.ts b/app/react/docker/tasks/queries/useTask.ts new file mode 100644 index 000000000..1acbdc9b1 --- /dev/null +++ b/app/react/docker/tasks/queries/useTask.ts @@ -0,0 +1,19 @@ +import { Task } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { TaskId } from '@/react/docker/tasks/types'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +export async function getTask(environmentId: EnvironmentId, taskId: TaskId) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'tasks', taskId) + ); + + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to get task'); + } +} diff --git a/app/react/docker/tasks/queries/useTaskLogs.ts b/app/react/docker/tasks/queries/useTaskLogs.ts new file mode 100644 index 000000000..9f54105b3 --- /dev/null +++ b/app/react/docker/tasks/queries/useTaskLogs.ts @@ -0,0 +1,25 @@ +import _ from 'lodash'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { TaskId, TaskLogsParams } from '@/react/docker/tasks/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +export async function getTaskLogs( + environmentId: EnvironmentId, + taskId: TaskId, + params?: TaskLogsParams +): Promise { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'tasks', taskId, 'logs'), + { + params: _.pickBy(params), + } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to get task logs'); + } +} diff --git a/app/react/docker/tasks/types.ts b/app/react/docker/tasks/types.ts new file mode 100644 index 000000000..8c1f1eb7d --- /dev/null +++ b/app/react/docker/tasks/types.ts @@ -0,0 +1,11 @@ +import { Task } from 'docker-types/generated/1.41'; + +export type TaskId = NonNullable; + +export type TaskLogsParams = { + stdout?: boolean; + stderr?: boolean; + timestamps?: boolean; + since?: number; + tail?: number; +}; diff --git a/app/react/docker/types.ts b/app/react/docker/types.ts index 15cabc864..52daea984 100644 --- a/app/react/docker/types.ts +++ b/app/react/docker/types.ts @@ -4,7 +4,7 @@ interface AgentMetadata { NodeName: string; } -export interface PortainerMetadata { +interface PortainerMetadata { ResourceControl?: ResourceControlResponse; Agent?: AgentMetadata; } diff --git a/app/react/docker/volumes/ListView/VolumesDatatable/columns/index.ts b/app/react/docker/volumes/ListView/VolumesDatatable/columns/index.ts index 18a44d511..b2f359ede 100644 --- a/app/react/docker/volumes/ListView/VolumesDatatable/columns/index.ts +++ b/app/react/docker/volumes/ListView/VolumesDatatable/columns/index.ts @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { createOwnershipColumn } from '@/react/docker/components/datatables/createOwnershipColumn'; import { isoDate, truncateLeftRight } from '@/portainer/filters/filters'; import { DecoratedVolume } from '../../types'; diff --git a/app/react/docker/volumes/queries/useCreateVolumeMutation.ts b/app/react/docker/volumes/queries/useCreateVolumeMutation.ts new file mode 100644 index 000000000..c51c693b3 --- /dev/null +++ b/app/react/docker/volumes/queries/useCreateVolumeMutation.ts @@ -0,0 +1,36 @@ +import { Volume } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; + +export type VolumeConfiguration = { + Name?: Volume['Name']; // docker auto generates if empty + Driver?: Volume['Driver']; // docker uses "local" if empty + DriverOpts?: Volume['Options']; + Labels?: Volume['Labels']; +}; + +export async function createVolume( + environmentId: EnvironmentId, + volumeConfiguration: VolumeConfiguration, + { nodeName }: { nodeName: string } +) { + try { + const { data } = await axios.post( + buildDockerProxyUrl(environmentId, 'volumes', 'create'), + volumeConfiguration, + { + headers: { + 'X-Portainer-VolumeName': volumeConfiguration.Name, + ...withAgentTargetHeader(nodeName), + }, + } + ); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to create volume'); + } +} diff --git a/app/react/docker/volumes/queries/useRemoveVolumeMutation.ts b/app/react/docker/volumes/queries/useRemoveVolumeMutation.ts new file mode 100644 index 000000000..1c4720afd --- /dev/null +++ b/app/react/docker/volumes/queries/useRemoveVolumeMutation.ts @@ -0,0 +1,23 @@ +import { Volume } from 'docker-types/generated/1.41'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; + +export async function removeVolume( + environmentId: EnvironmentId, + name: Volume['Name'], + { nodeName }: { nodeName: string } +) { + try { + await axios.delete(buildDockerProxyUrl(environmentId, 'volumes', name), { + headers: { + ...withAgentTargetHeader(nodeName), + }, + }); + } catch (e) { + throw parseAxiosError(e, 'Unable to remove volume'); + } +} diff --git a/app/react/docker/volumes/queries/useVolume.ts b/app/react/docker/volumes/queries/useVolume.ts new file mode 100644 index 000000000..704d95f31 --- /dev/null +++ b/app/react/docker/volumes/queries/useVolume.ts @@ -0,0 +1,26 @@ +import { Volume } from 'docker-types/generated/1.41'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +/** + * Raw docker API query + * @param environmentId + * @param name + * @returns + */ +export async function getVolume( + environmentId: EnvironmentId, + name: Volume['Name'] +) { + try { + const { data } = await axios.get( + buildDockerProxyUrl(environmentId, 'volumes', name) + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve volume details'); + } +} diff --git a/app/react/docker/volumes/queries/useVolumes.ts b/app/react/docker/volumes/queries/useVolumes.ts index 92cac3026..f914fcca8 100644 --- a/app/react/docker/volumes/queries/useVolumes.ts +++ b/app/react/docker/volumes/queries/useVolumes.ts @@ -2,9 +2,11 @@ import { useQuery } from 'react-query'; import { Volume } from 'docker-types/generated/1.41'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { buildUrl as buildDockerUrl } from '@/react/docker/proxy/queries/build-url'; +import { buildDockerProxyUrl } from '@/react/docker/proxy/queries/buildDockerProxyUrl'; import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withFiltersQueryParam } from '../../proxy/queries/utils'; + import { queryKeys } from './query-keys'; export function useVolumes( @@ -22,10 +24,40 @@ interface VolumesResponse { Volumes: Volume[]; } -export async function getVolumes(environmentId: EnvironmentId) { +type Filters = { + /** + * When set to true (or 1), returns all volumes that are not in use by a container. + * When set to false (or 0), only volumes that are in use by one or more containers are returned. + */ + dangling?: ['true' | 'false']; + /** + * + * Matches volumes based on their driver. + */ + driver?: string; + /** + * or : + * Matches volumes based on the presence of a label alone or a label and a value. + */ + label?: string; + /** + * Matches all or part of a volume name. + */ + name?: Volume['Name']; +}; + +export async function getVolumes( + environmentId: EnvironmentId, + filters?: Filters +) { try { const { data } = await axios.get( - buildUrl(environmentId, 'volumes') + buildDockerProxyUrl(environmentId, 'volumes'), + { + params: { + ...withFiltersQueryParam(filters), + }, + } ); return data.Volumes; @@ -33,13 +65,3 @@ export async function getVolumes(environmentId: EnvironmentId) { throw parseAxiosError(error as Error, 'Unable to retrieve volumes'); } } - -function buildUrl(environmentId: EnvironmentId, action: string, id?: string) { - let url = buildDockerUrl(environmentId, action); - - if (id) { - url += `/${id}`; - } - - return url; -} diff --git a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx index 5a03b6c5b..e36f6acf4 100644 --- a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx +++ b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx @@ -9,7 +9,7 @@ import { TeamId } from '@/react/portainer/users/teams/types'; import { useTeams } from '@/react/portainer/users/teams/queries'; import { useUsers } from '@/portainer/users/queries'; import { pluralize } from '@/portainer/helpers/strings'; -import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { ownershipIcon } from '@/react/docker/components/datatables/createOwnershipColumn'; import { Link } from '@@/Link'; import { Tooltip } from '@@/Tip/Tooltip'; diff --git a/app/react/portainer/access-control/EditDetails/useOptions.tsx b/app/react/portainer/access-control/EditDetails/useOptions.tsx index af2194798..7bc458ce5 100644 --- a/app/react/portainer/access-control/EditDetails/useOptions.tsx +++ b/app/react/portainer/access-control/EditDetails/useOptions.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { buildOption } from '@/portainer/components/BoxSelector'; import { Team } from '@/react/portainer/users/teams/types'; -import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn'; +import { ownershipIcon } from '@/react/docker/components/datatables/createOwnershipColumn'; import { BoxSelectorOption } from '@@/BoxSelector/types'; import { BadgeIcon } from '@@/BadgeIcon'; diff --git a/app/types.ts b/app/types.ts index 4521a2bfb..41ef7c7c6 100644 --- a/app/types.ts +++ b/app/types.ts @@ -14,6 +14,7 @@ declare module 'react' { AutomationTestingProps {} } -export type WithRequiredProperty = Type & { - [Property in Key]-?: Type[Property]; -}; +export type WithRequiredProperties = Omit & + Required>; + +export type ValueOf> = T[keyof T]; diff --git a/app/types/deepPick.ts b/app/types/deepPick.ts new file mode 100644 index 000000000..276615304 --- /dev/null +++ b/app/types/deepPick.ts @@ -0,0 +1,82 @@ +import { DeepRequired } from './deepRequired'; +import { Shift } from './shift'; +import { ToTuple } from './toTuple'; +import { PathToStringArray, Prettify } from './utils'; + +/** + * Pick and union the types of the paths (or path-arrays). + * + * To be able to properly pick, it marks all segments of the paths required, even the last. + * + * The resulting type will never be optional + * + * @example + * type SubExample = { + * something: number; + * }; + * + * type Example = { + * always: string; + * a?: { + * two?: boolean; + * b?: { + * c?: number; + * }; + * }; + * + * one?: { + * two?: { + * three?: SubExample; + * four?: number; + * }; + * }; + * }; + * + * type DeepPickExampleArray = DeepPick; + * type DeepPickExampleString = DeepPick; + * type DeepPickExampleMix = DeepPick; + * + * // they all equal to + * type ResultingType = + * | number // picked from 'a.b.c' + * | boolean // picked from 'a.two' + * | { something: number }; // picked from 'one.two.three' + * + */ +export type DeepPick = Prettify< + P extends string + ? DeepPickFromString, P> + : P extends string[] + ? DeepPickFromArray, P> + : never +>; + +/** + * DeepPick and transform union of paths to union of path-arrays + * from `'a.b.c' | 'c.d.e'` to `['a', 'b', 'c'] | ['c' | 'd' | 'e']` + */ +type DeepPickFromString = DeepPickFromArray< + T, + PathToStringArray

+>; + +/** + * Transform union of path-arrays to tuple of path-arrays + * from `['a', 'b', 'c'] | ['c' | 'd' | 'e']` to `[['a', 'b', 'c'], ['c' | 'd' | 'e']]` + */ +type DeepPickFromArray< + T extends object, + P extends string[], +> = ToTuple

extends string[][] ? DeepPickRec> : never; + +// Recursively pick each path-array of tuple and union the resulting types +type DeepPickRec = P[0] extends string[] + ? PickOne | DeepPickRec> + : never; + +// Recursively pick each element of path-array +type PickOne = P[0] extends keyof T + ? T[P[0]] extends object + ? PickOne> + : T[P[0]] + : T; diff --git a/app/types/deepRequired.ts b/app/types/deepRequired.ts new file mode 100644 index 000000000..4f5e692a3 --- /dev/null +++ b/app/types/deepRequired.ts @@ -0,0 +1,68 @@ +import { ShiftUnion } from './shift'; +import { PathToStringArray, Prettify } from './utils'; + +/** + * Recursively make all paths (or path-arrays) required + * + * @example + * type SubExample = { + * something: number; + * }; + * + * type Example = { + * always: string; + * a?: { + * two?: boolean; + * b?: { + * c?: number; + * }; + * }; + * + * one?: { + * two?: { + * three?: SubExample; + * four?: number; + * }; + * }; + * }; + * + * type DeepRequiredExampleArray = DeepRequired; + * type DeepRequiredExampleString = DeepRequired; + * type DeepRequiredExampleMix = DeepRequired; + * + * // they all equal to + * type ResultingType = { + * always: string; + * a: { // became required + * two?: boolean | undefined; + * b: { // became required + * c: number; // deep required + * }; + * }; + * one: { // became required + * two: { // became required + * four?: number | undefined; + * three: { // deep required + * something: number; + * }; + * }; + * }; + * }; + * + */ +export type DeepRequired = Prettify< + DeepRequiredRec : P> +>; + +/** + * Recursively require all elements of path-array + * The Omit part is there to make the key a hard require + */ +type DeepRequiredRec = T extends object + ? Omit> & + Required<{ + [K in Extract]: NonNullable< + DeepRequiredRec> + >; + }> + : T; diff --git a/app/types/shift.ts b/app/types/shift.ts new file mode 100644 index 000000000..ee54a5136 --- /dev/null +++ b/app/types/shift.ts @@ -0,0 +1,27 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// required `any` for Distributive Conditional +// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types + +/** + * Remove the first element (allows to move forward path-arrays) + */ +export type Shift = ((...t: T) => any) extends ( + first: any, + ...rest: infer Rest +) => any + ? Rest + : never; + +/** + * Using Distributive Conditional, allows to move forward on union of path-arrays + * + * @example + * type Result = ShiftUnion<['a', 'b'] | ['c', 'd']> // ['b'] | ['d'] + */ +export type ShiftUnion

= T extends any[] + ? T[0] extends P + ? Shift + : never + : never; + +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/app/types/toTuple.ts b/app/types/toTuple.ts new file mode 100644 index 000000000..a62896b92 --- /dev/null +++ b/app/types/toTuple.ts @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// required `any` for Distributive Conditional +// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types + +/** + * Transform a union to a tuple + * from 'a' | 'b' | ['c', 'd'] to `['a', 'b', ['c', 'd']]` + */ +export type ToTuple = ToTupleRec; + +// Recursively build a tuple from a union +type ToTupleRec = SpliceOne extends never + ? [ExtractOne, ...Result] + : ToTupleRec, [ExtractOne, ...Result]>; + +// Remove the first element of union +type SpliceOne = Exclude>; + +// Extract the first element of union +type ExtractOne = ExtractParam>>; + +/** + * Extract param of function + * + * Here, used with an intersection of functions generated by UnionToIntersection to pick the first type of intersection + * + * @example + * type EP = ExtractParam<((k: 'a') => void) & ((k: 'b') => void)> // 'a' + */ +type ExtractParam = F extends { (a: infer A): void } ? A : never; + +/** + * When called with a union of functions, allows to generate an intersection of the functions params types + * + * --- + * + * In our usage + * ``` + * type Inter = UnionToIntersection>; + * // equals + * type Inter = UnionToIntersection<((k: 'a') => void) | ((k: 'b') => void)>; + * // which expands to + * type Inter = + * | ((k: (k: 'a') => void) => void) + * | ((k: (k: 'b') => void) => void) extends (k: infer I) => void + * ? I + * : never; + * // using the contra-variant positions, an intersection is inferred. The result is then + * type Inter = ((k: 'a') => void) & ((k: 'b') => void); + * // (infer I) of 1st Union elem & (infer I) of 2nd Union element + * ``` + * + * --- + * + * For more details see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#type-inference-in-conditional-types + * + * ``` + * // The following example demonstrates how multiple candidates for the same type variable in co-variant positions causes a union type to be inferred: + * type Foo = T extends { a: infer U; b: infer U } ? U : never; + * type T10 = Foo<{ a: string; b: string }>; // string + * type T11 = Foo<{ a: string; b: number }>; // string | number + * + * // Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred: + * type Bar = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never; + * type T20 = Bar<{ a: (x: string) => void; b: (x: string) => void }>; // string + * type T21 = Bar<{ a: (x: string) => void; b: (x: number) => void }>; // string & number + * ``` + * + */ +type UnionToIntersection = UnionToParam extends (k: infer I) => void + ? I + : never; + +/** + * Transform T to `(k: T) => void` (excluding never) + * + * When called with a union of functions, generates a union of functions taking a function as param + * + * @example + * type U = UnionToParam<'a' | 'b'>; + * // = ((k: "a") => void) | ((k: "b") => void) + * + * @example + * type U2 = UnionToParam>; + * // = ((k: (k: "a") => void) => void) | ((k: (k: "b") => void) => void) + */ +type UnionToParam = U extends any ? (k: U) => void : never; + +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/app/types/utils.ts b/app/types/utils.ts new file mode 100644 index 000000000..917a4f91a --- /dev/null +++ b/app/types/utils.ts @@ -0,0 +1,21 @@ +/** + * Transform `'a.b.c'` or `['a', 'b', 'c']` to `['a', 'b', 'c']` + */ +export type PathToStringArray = T extends string[] + ? T + : T extends `${infer Head}.${infer Tail}` + ? [...PathToStringArray, ...PathToStringArray] + : [T]; + +/* eslint-disable @typescript-eslint/ban-types */ +/** + * VSCode helper to recursively pretty print the constructed types instead of + * displaying the sub types. + * + * Particularly useful to see the resulting type when using `DeepRequired` and `DeepPick`. + * Both already use them at root. + */ +export type Prettify = { + [K in keyof T]: Prettify; +} & {}; +/* eslint-enable @typescript-eslint/ban-types */