From af77e33993d018800ed42c3f38f5a1f3be285d6d Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Mon, 29 May 2023 15:06:14 +1200 Subject: [PATCH] refactor(app): details widget migration [EE-5352] (#8886) --- .vscode.example/portainer.code-snippets | 9 + app/docker/react/components/index.ts | 2 +- .../helpers/application/rollback.js | 76 ----- app/kubernetes/helpers/history/daemonset.js | 27 -- app/kubernetes/helpers/history/deployment.js | 56 ---- app/kubernetes/helpers/history/index.js | 50 --- app/kubernetes/helpers/history/statefulset.js | 27 -- .../horizontal-pod-auto-scaler/service.js | 17 - .../models/application/models/index.js | 2 - app/kubernetes/models/history/models.js | 1 - app/kubernetes/react/components/index.ts | 12 +- app/kubernetes/services/applicationService.js | 18 -- app/kubernetes/services/daemonSetService.js | 18 -- app/kubernetes/services/deploymentService.js | 18 -- app/kubernetes/services/historyService.js | 58 ---- app/kubernetes/services/replicaSetService.js | 31 -- app/kubernetes/services/statefulSetService.js | 18 -- .../views/applications/edit/application.html | 303 +----------------- .../edit/applicationController.js | 146 --------- .../ingress-table/ingress-table.controller.js | 30 -- .../ingress-table/ingress-table.html | 28 -- .../components/ingress-table/ingress-table.js | 11 - .../services-table/services-table.html | 62 ---- .../services-table/services-table.js | 10 - .../stack-redeploy-git-form.controller.js | 2 +- .../views/stacks/edit/stackController.js | 4 +- .../stacks/CreateView/.keep | 0 .../{docker => common}/stacks/ItemView/.keep | 0 .../ItemView/StackContainersDatatable.tsx | 4 +- .../{docker => common}/stacks/ListView/.keep | 0 .../stacks/common/confirm-stack-update.ts | 0 app/react/common/stacks/readme.md | 1 + app/react/common/stacks/stack.service.ts | 25 ++ app/react/{docker => common}/stacks/types.ts | 4 + app/react/kubernetes/ServicesView/service.ts | 24 +- .../ApplicationAutoScalingTable.tsx | 74 +++++ .../ApplicationDetailsWidget.tsx | 142 ++++++++ .../ApplicationEnvVarsTable.tsx | 172 ++++++++++ .../ApplicationIngressesTable.tsx | 125 ++++++++ .../ApplicationPersistentDataTable.tsx | 268 ++++++++++++++++ .../ApplicationServicesTable.tsx | 130 ++++++++ .../ApplicationVolumeConfigsTable.tsx | 148 +++++++++ .../RedeployApplicationButton.tsx | 101 ++++++ .../RestartApplicationButton.tsx | 19 ++ .../RollbackApplicationButton.tsx | 130 ++++++++ .../ApplicationDetailsWidget/index.ts | 1 + .../DetailsView/ApplicationSummaryWidget.tsx | 14 +- .../applications/DetailsView/index.ts | 1 + .../applications/application.queries.ts | 283 +++++++++++++++- .../applications/application.service.ts | 159 +++++++-- .../applications/autoscaling.service.ts | 14 + .../kubernetes/applications/constants.ts | 16 + .../kubernetes/applications/pod.service.ts | 41 ++- app/react/kubernetes/applications/types.ts | 15 + app/react/kubernetes/applications/utils.ts | 174 +++++++++- .../portainer/gitops/RefField/RefField.tsx | 2 +- .../portainer/gitops/RefField/RefSelector.tsx | 2 +- 57 files changed, 2046 insertions(+), 1079 deletions(-) delete mode 100644 app/kubernetes/helpers/application/rollback.js delete mode 100644 app/kubernetes/helpers/history/daemonset.js delete mode 100644 app/kubernetes/helpers/history/deployment.js delete mode 100644 app/kubernetes/helpers/history/index.js delete mode 100644 app/kubernetes/helpers/history/statefulset.js delete mode 100644 app/kubernetes/services/historyService.js delete mode 100644 app/kubernetes/services/replicaSetService.js delete mode 100644 app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.controller.js delete mode 100644 app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.html delete mode 100644 app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.js delete mode 100644 app/kubernetes/views/applications/edit/components/services-table/services-table.html delete mode 100644 app/kubernetes/views/applications/edit/components/services-table/services-table.js rename app/react/{docker => common}/stacks/CreateView/.keep (100%) rename app/react/{docker => common}/stacks/ItemView/.keep (100%) rename app/react/{docker => common}/stacks/ItemView/StackContainersDatatable.tsx (95%) rename app/react/{docker => common}/stacks/ListView/.keep (100%) rename app/react/{docker => common}/stacks/common/confirm-stack-update.ts (100%) create mode 100644 app/react/common/stacks/readme.md create mode 100644 app/react/common/stacks/stack.service.ts rename app/react/{docker => common}/stacks/types.ts (87%) create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationAutoScalingTable.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationIngressesTable.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationPersistentDataTable.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationServicesTable.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RedeployApplicationButton.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RestartApplicationButton.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RollbackApplicationButton.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/index.ts create mode 100644 app/react/kubernetes/applications/autoscaling.service.ts diff --git a/.vscode.example/portainer.code-snippets b/.vscode.example/portainer.code-snippets index 9c7716c7c..0021850f2 100644 --- a/.vscode.example/portainer.code-snippets +++ b/.vscode.example/portainer.code-snippets @@ -15,6 +15,15 @@ // ], // "description": "Log output to console" // } + "React Named Export Component": { + "prefix": "rnec", + "body": [ + "export function $TM_FILENAME_BASE() {", + " return
$TM_FILENAME_BASE
;", + "}" + ], + "description": "React Named Export Component" + }, "Component": { "scope": "javascript", "prefix": "mycomponent", diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts index 67ce16b1a..87bbfc607 100644 --- a/app/docker/react/components/index.ts +++ b/app/docker/react/components/index.ts @@ -2,7 +2,7 @@ import angular from 'angular'; import { r2a } from '@/react-tools/react2angular'; import { withControlledInput } from '@/react-tools/withControlledInput'; -import { StackContainersDatatable } from '@/react/docker/stacks/ItemView/StackContainersDatatable'; +import { StackContainersDatatable } from '@/react/common/stacks/ItemView/StackContainersDatatable'; import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions'; import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown'; import { TemplateListSortAngular } from '@/react/docker/app-templates/TemplateListSort'; diff --git a/app/kubernetes/helpers/application/rollback.js b/app/kubernetes/helpers/application/rollback.js deleted file mode 100644 index 7f110e8f8..000000000 --- a/app/kubernetes/helpers/application/rollback.js +++ /dev/null @@ -1,76 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash-es'; -import PortainerError from 'Portainer/error'; - -import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; -import { KubernetesSystem_DefaultDeploymentUniqueLabelKey, KubernetesSystem_AnnotationsToSkip } from 'Kubernetes/models/history/models'; - -class KubernetesApplicationRollbackHelper { - static getPatchPayload(application, targetRevision) { - let result; - - switch (application.ApplicationType) { - case KubernetesApplicationTypes.DEPLOYMENT: - result = KubernetesApplicationRollbackHelper._getDeploymentPayload(application, targetRevision); - break; - case KubernetesApplicationTypes.DAEMONSET: - result = KubernetesApplicationRollbackHelper._getDaemonSetPayload(application, targetRevision); - break; - case KubernetesApplicationTypes.STATEFULSET: - result = KubernetesApplicationRollbackHelper._getStatefulSetPayload(application, targetRevision); - break; - default: - throw new PortainerError('Unable to determine which association to use to convert patch'); - } - return result; - } - - static _getDeploymentPayload(deploymentApp, targetRevision) { - const target = angular.copy(targetRevision); - const deployment = deploymentApp.Raw; - - // remove hash label before patching back into the deployment - delete target.spec.template.metadata.labels[KubernetesSystem_DefaultDeploymentUniqueLabelKey]; - - // compute deployment annotations - const annotations = {}; - _.forEach(KubernetesSystem_AnnotationsToSkip, (_, k) => { - const v = deployment.metadata.annotations[k]; - if (v) { - annotations[k] = v; - } - }); - _.forEach(target.metadata.annotations, (v, k) => { - if (!KubernetesSystem_AnnotationsToSkip[k]) { - annotations[k] = v; - } - }); - // Create a patch of the Deployment that replaces spec.template - const patch = [ - { - op: 'replace', - path: '/spec/template', - value: target.spec.template, - }, - { - op: 'replace', - path: '/metadata/annotations', - value: annotations, - }, - ]; - - return patch; - } - - static _getDaemonSetPayload(daemonSet, targetRevision) { - void daemonSet; - return targetRevision.data; - } - - static _getStatefulSetPayload(statefulSet, targetRevision) { - void statefulSet; - return targetRevision.data; - } -} - -export default KubernetesApplicationRollbackHelper; diff --git a/app/kubernetes/helpers/history/daemonset.js b/app/kubernetes/helpers/history/daemonset.js deleted file mode 100644 index 506a0f0b1..000000000 --- a/app/kubernetes/helpers/history/daemonset.js +++ /dev/null @@ -1,27 +0,0 @@ -import _ from 'lodash-es'; - -class KubernetesDaemonSetHistoryHelper { - static _isControlledBy(daemonSet) { - return (item) => _.find(item.metadata.ownerReferences, { uid: daemonSet.metadata.uid }) !== undefined; - } - - static filterOwnedRevisions(crList, daemonSet) { - // filter ControllerRevisions that has the same selector as the DaemonSet - // NOTE : this should be done in HTTP request based on daemonSet.spec.selector.matchLabels - // instead of getting all CR and filtering them here - const sameLabelsCR = _.filter(crList, ['metadata.labels', daemonSet.spec.selector.matchLabels]); - // Only include the RS whose ControllerRef matches the DaemonSet. - const controlledCR = _.filter(sameLabelsCR, KubernetesDaemonSetHistoryHelper._isControlledBy(daemonSet)); - // sorts the list of ControllerRevisions by revision, using the creationTimestamp as a tie breaker (old to new) - const sortedList = _.sortBy(controlledCR, ['revision', 'metadata.creationTimestamp']); - return sortedList; - } - - // getCurrentRS returns the newest CR the given daemonSet targets (latest version) - static getCurrentRevision(crList) { - const current = _.last(crList); - return current; - } -} - -export default KubernetesDaemonSetHistoryHelper; diff --git a/app/kubernetes/helpers/history/deployment.js b/app/kubernetes/helpers/history/deployment.js deleted file mode 100644 index 96974eedc..000000000 --- a/app/kubernetes/helpers/history/deployment.js +++ /dev/null @@ -1,56 +0,0 @@ -import _ from 'lodash-es'; -import angular from 'angular'; -import { KubernetesSystem_DefaultDeploymentUniqueLabelKey, KubernetesSystem_RevisionAnnotation } from 'Kubernetes/models/history/models'; - -class KubernetesDeploymentHistoryHelper { - static _isControlledBy(deployment) { - return (item) => _.find(item.metadata.ownerReferences, { uid: deployment.metadata.uid }) !== undefined; - } - - static filterOwnedRevisions(rsList, deployment) { - // filter RS that has the same selector as the Deployment - // NOTE : this should be done in HTTP request based on deployment.spec.selector - // instead of getting all RS and filtering them here - const sameLabelsRS = _.filter(rsList, ['spec.selector', deployment.spec.selector]); - // Only include the RS whose ControllerRef matches the Deployment. - const controlledRS = _.filter(sameLabelsRS, KubernetesDeploymentHistoryHelper._isControlledBy(deployment)); - // sorts the list of ReplicaSet by creation timestamp, using the names as a tie breaker (old to new) - const sortedList = _.sortBy(controlledRS, ['metadata.creationTimestamp', 'metadata.name']); - return sortedList; - } - - // getCurrentRS returns the new RS the given deployment targets (the one with the same pod template). - static getCurrentRevision(rsListOriginal, deployment) { - const rsList = angular.copy(rsListOriginal); - - // In rare cases, such as after cluster upgrades, Deployment may end up with - // having more than one new ReplicaSets that have the same template as its template, - // see https://github.com/kubernetes/kubernetes/issues/40415 - // We deterministically choose the oldest new ReplicaSet (first match) - const current = _.find(rsList, (item) => { - // returns true if two given template.spec are equal, ignoring the diff in value of Labels[pod-template-hash] - // We ignore pod-template-hash because: - // 1. The hash result would be different upon podTemplateSpec API changes - // (e.g. the addition of a new field will cause the hash code to change) - // 2. The deployment template won't have hash labels - delete item.spec.template.metadata.labels[KubernetesSystem_DefaultDeploymentUniqueLabelKey]; - return _.isEqual(deployment.spec.template, item.spec.template); - }); - current.revision = current.metadata.annotations[KubernetesSystem_RevisionAnnotation]; - return current; - } - - // filters the RSList to drop all RS that have never been a version of the Deployment - // also add the revision as a field inside the RS - // Note: this should not impact rollback process as we only patch - // metadata.annotations and spec.template - static filterVersionedRevisions(rsList) { - const filteredRS = _.filter(rsList, (item) => item.metadata.annotations[KubernetesSystem_RevisionAnnotation] !== undefined); - return _.map(filteredRS, (item) => { - item.revision = item.metadata.annotations[KubernetesSystem_RevisionAnnotation]; - return item; - }); - } -} - -export default KubernetesDeploymentHistoryHelper; diff --git a/app/kubernetes/helpers/history/index.js b/app/kubernetes/helpers/history/index.js deleted file mode 100644 index 5f8851ab4..000000000 --- a/app/kubernetes/helpers/history/index.js +++ /dev/null @@ -1,50 +0,0 @@ -import _ from 'lodash-es'; -import PortainerError from 'Portainer/error'; - -import KubernetesDeploymentHistoryHelper from 'Kubernetes/helpers/history/deployment'; -import KubernetesDaemonSetHistoryHelper from 'Kubernetes/helpers/history/daemonset'; -import KubernetesStatefulSetHistoryHelper from 'Kubernetes/helpers/history/statefulset'; -import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; - -class KubernetesHistoryHelper { - static getRevisions(rawRevisions, application) { - let currentRevision, revisionsList; - - switch (application.ApplicationType) { - case KubernetesApplicationTypes.DEPLOYMENT: - [currentRevision, revisionsList] = KubernetesHistoryHelper._getDeploymentRevisions(rawRevisions, application.Raw); - break; - case KubernetesApplicationTypes.DAEMONSET: - [currentRevision, revisionsList] = KubernetesHistoryHelper._getDaemonSetRevisions(rawRevisions, application.Raw); - break; - case KubernetesApplicationTypes.STATEFULSET: - [currentRevision, revisionsList] = KubernetesHistoryHelper._getStatefulSetRevisions(rawRevisions, application.Raw); - break; - default: - throw new PortainerError('Unable to determine which association to use to get revisions'); - } - revisionsList = _.sortBy(revisionsList, 'revision'); - return [currentRevision, revisionsList]; - } - - static _getDeploymentRevisions(rsList, deployment) { - const appRS = KubernetesDeploymentHistoryHelper.filterOwnedRevisions(rsList, deployment); - const currentRS = KubernetesDeploymentHistoryHelper.getCurrentRevision(appRS, deployment); - const versionedRS = KubernetesDeploymentHistoryHelper.filterVersionedRevisions(appRS); - return [currentRS, versionedRS]; - } - - static _getDaemonSetRevisions(crList, daemonSet) { - const appCR = KubernetesDaemonSetHistoryHelper.filterOwnedRevisions(crList, daemonSet); - const currentCR = KubernetesDaemonSetHistoryHelper.getCurrentRevision(appCR, daemonSet); - return [currentCR, appCR]; - } - - static _getStatefulSetRevisions(crList, statefulSet) { - const appCR = KubernetesStatefulSetHistoryHelper.filterOwnedRevisions(crList, statefulSet); - const currentCR = KubernetesStatefulSetHistoryHelper.getCurrentRevision(appCR, statefulSet); - return [currentCR, appCR]; - } -} - -export default KubernetesHistoryHelper; diff --git a/app/kubernetes/helpers/history/statefulset.js b/app/kubernetes/helpers/history/statefulset.js deleted file mode 100644 index 829cf0181..000000000 --- a/app/kubernetes/helpers/history/statefulset.js +++ /dev/null @@ -1,27 +0,0 @@ -import _ from 'lodash-es'; - -class KubernetesStatefulSetHistoryHelper { - static _isControlledBy(statefulSet) { - return (item) => _.find(item.metadata.ownerReferences, { uid: statefulSet.metadata.uid }) !== undefined; - } - - static filterOwnedRevisions(crList, statefulSet) { - // filter ControllerRevisions that has the same selector as the StatefulSet - // NOTE : this should be done in HTTP request based on statefulSet.spec.selector.matchLabels - // instead of getting all CR and filtering them here - const sameLabelsCR = _.filter(crList, ['metadata.labels', statefulSet.spec.selector.matchLabels]); - // Only include the RS whose ControllerRef matches the StatefulSet. - const controlledCR = _.filter(sameLabelsCR, KubernetesStatefulSetHistoryHelper._isControlledBy(statefulSet)); - // sorts the list of ControllerRevisions by revision, using the creationTimestamp as a tie breaker (old to new) - const sortedList = _.sortBy(controlledCR, ['revision', 'metadata.creationTimestamp']); - return sortedList; - } - - // getCurrentRS returns the newest CR the given statefulSet targets (latest version) - static getCurrentRevision(crList) { - const current = _.last(crList); - return current; - } -} - -export default KubernetesStatefulSetHistoryHelper; diff --git a/app/kubernetes/horizontal-pod-auto-scaler/service.js b/app/kubernetes/horizontal-pod-auto-scaler/service.js index d9ab6f50f..e26ef6d0e 100644 --- a/app/kubernetes/horizontal-pod-auto-scaler/service.js +++ b/app/kubernetes/horizontal-pod-auto-scaler/service.js @@ -112,23 +112,6 @@ class KubernetesHorizontalPodAutoScalerService { delete(horizontalPodAutoScaler) { return this.$async(this.deleteAsync, horizontalPodAutoScaler); } - - // /** - // * ROLLBACK - // */ - // async rollbackAsync(namespace, name, payload) { - // try { - // const params = new KubernetesCommonParams(); - // params.id = name; - // await this.KubernetesHorizontalPodAutoScalers(namespace).rollback(params, payload).$promise; - // } catch (err) { - // throw new PortainerError('Unable to rollback horizontalPodAutoScaler', err); - // } - // } - - // rollback(namespace, name, payload) { - // return this.$async(this.rollbackAsync, namespace, name, payload); - // } } export default KubernetesHorizontalPodAutoScalerService; diff --git a/app/kubernetes/models/application/models/index.js b/app/kubernetes/models/application/models/index.js index 9085a7219..0391a7f86 100644 --- a/app/kubernetes/models/application/models/index.js +++ b/app/kubernetes/models/application/models/index.js @@ -35,8 +35,6 @@ const _KubernetesApplication = Object.freeze({ TotalPodsCount: 0, Yaml: '', Note: '', - Revisions: undefined, - CurrentRevision: undefined, Raw: undefined, // only filled when inspecting app details / create / edit view (never filled in multiple-apps views) AutoScaler: undefined, // only filled if the application has an HorizontalPodAutoScaler bound to it }); diff --git a/app/kubernetes/models/history/models.js b/app/kubernetes/models/history/models.js index 8a8cad6a7..026aaffa3 100644 --- a/app/kubernetes/models/history/models.js +++ b/app/kubernetes/models/history/models.js @@ -1,7 +1,6 @@ export const KubernetesSystem_DefaultDeploymentUniqueLabelKey = 'pod-template-hash'; export const KubernetesSystem_RevisionAnnotation = 'deployment.kubernetes.io/revision'; export const KubernetesSystem_RevisionHistoryAnnotation = 'deployment.kubernetes.io/revision-history'; -export const KubernetesSystem_ChangeCauseAnnotation = 'kubernetes.io/change-cause'; export const KubernetesSystem_DesiredReplicasAnnotation = 'deployment.kubernetes.io/desired-replicas'; export const KubernetesSystem_MaxReplicasAnnotation = 'deployment.kubernetes.io/max-replicas'; diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 44466c181..536ae597f 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -8,9 +8,12 @@ import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/Acce import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector'; import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector'; import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector'; -import { ApplicationSummaryWidget } from '@/react/kubernetes/applications/DetailsView'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; +import { + ApplicationSummaryWidget, + ApplicationDetailsWidget, +} from '@/react/kubernetes/applications/DetailsView'; import { withUserProvider } from '@/react/test-utils/withUserProvider'; export const componentsModule = angular @@ -93,4 +96,11 @@ export const componentsModule = angular withUIRouter(withReactQuery(withUserProvider(ApplicationSummaryWidget))), [] ) + ) + .component( + 'applicationDetailsWidget', + r2a( + withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))), + [] + ) ).name; diff --git a/app/kubernetes/services/applicationService.js b/app/kubernetes/services/applicationService.js index ff8ef3ec9..8ce012bef 100644 --- a/app/kubernetes/services/applicationService.js +++ b/app/kubernetes/services/applicationService.js @@ -4,7 +4,6 @@ import PortainerError from 'Portainer/error'; import { KubernetesApplication, KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; -import KubernetesApplicationRollbackHelper from 'Kubernetes/helpers/application/rollback'; import KubernetesApplicationConverter from 'Kubernetes/converters/application'; import { KubernetesDeployment } from 'Kubernetes/models/deployment/models'; import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models'; @@ -29,7 +28,6 @@ class KubernetesApplicationService { KubernetesPersistentVolumeClaimService, KubernetesNamespaceService, KubernetesPodService, - KubernetesHistoryService, KubernetesHorizontalPodAutoScalerService, KubernetesIngressService ) { @@ -43,7 +41,6 @@ class KubernetesApplicationService { this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; this.KubernetesNamespaceService = KubernetesNamespaceService; this.KubernetesPodService = KubernetesPodService; - this.KubernetesHistoryService = KubernetesHistoryService; this.KubernetesHorizontalPodAutoScalerService = KubernetesHorizontalPodAutoScalerService; this.KubernetesIngressService = KubernetesIngressService; @@ -52,7 +49,6 @@ class KubernetesApplicationService { this.createAsync = this.createAsync.bind(this); this.patchAsync = this.patchAsync.bind(this); this.patchPartialAsync = this.patchPartialAsync.bind(this); - this.rollbackAsync = this.rollbackAsync.bind(this); this.deleteAsync = this.deleteAsync.bind(this); } /* #endregion */ @@ -123,8 +119,6 @@ class KubernetesApplicationService { application.AutoScaler = scaler; application.Ingresses = ingresses; - await this.KubernetesHistoryService.get(application); - if (service.Yaml) { application.Yaml += '---\n' + service.Yaml; } @@ -428,18 +422,6 @@ class KubernetesApplicationService { return this.$async(this.deleteAsync, application); } /* #endregion */ - - /* #region ROLLBACK */ - async rollbackAsync(application, targetRevision) { - const payload = KubernetesApplicationRollbackHelper.getPatchPayload(application, targetRevision); - const apiService = this._getApplicationApiService(application); - await apiService.rollback(application.ResourcePool, application.Name, payload); - } - - rollback(application, targetRevision) { - return this.$async(this.rollbackAsync, application, targetRevision); - } - /* #endregion */ } export default KubernetesApplicationService; diff --git a/app/kubernetes/services/daemonSetService.js b/app/kubernetes/services/daemonSetService.js index 6fbc38e9c..66e8895e6 100644 --- a/app/kubernetes/services/daemonSetService.js +++ b/app/kubernetes/services/daemonSetService.js @@ -13,7 +13,6 @@ class KubernetesDaemonSetService { this.getAllAsync = this.getAllAsync.bind(this); this.createAsync = this.createAsync.bind(this); this.patchAsync = this.patchAsync.bind(this); - this.rollbackAsync = this.rollbackAsync.bind(this); this.deleteAsync = this.deleteAsync.bind(this); } @@ -110,23 +109,6 @@ class KubernetesDaemonSetService { delete(daemonSet) { return this.$async(this.deleteAsync, daemonSet); } - - /** - * ROLLBACK - */ - async rollbackAsync(namespace, name, payload) { - try { - const params = new KubernetesCommonParams(); - params.id = name; - await this.KubernetesDaemonSets(namespace).rollback(params, payload).$promise; - } catch (err) { - throw new PortainerError('Unable to rollback daemonset', err); - } - } - - rollback(namespace, name, payload) { - return this.$async(this.rollbackAsync, namespace, name, payload); - } } export default KubernetesDaemonSetService; diff --git a/app/kubernetes/services/deploymentService.js b/app/kubernetes/services/deploymentService.js index fe0f4ac3b..e1c33004f 100644 --- a/app/kubernetes/services/deploymentService.js +++ b/app/kubernetes/services/deploymentService.js @@ -13,7 +13,6 @@ class KubernetesDeploymentService { this.getAllAsync = this.getAllAsync.bind(this); this.createAsync = this.createAsync.bind(this); this.patchAsync = this.patchAsync.bind(this); - this.rollbackAsync = this.rollbackAsync.bind(this); this.deleteAsync = this.deleteAsync.bind(this); } @@ -110,23 +109,6 @@ class KubernetesDeploymentService { delete(deployment) { return this.$async(this.deleteAsync, deployment); } - - /** - * ROLLBACK - */ - async rollbackAsync(namespace, name, payload) { - try { - const params = new KubernetesCommonParams(); - params.id = name; - await this.KubernetesDeployments(namespace).rollback(params, payload).$promise; - } catch (err) { - throw new PortainerError('Unable to rollback deployment', err); - } - } - - rollback(namespace, name, payload) { - return this.$async(this.rollbackAsync, namespace, name, payload); - } } export default KubernetesDeploymentService; diff --git a/app/kubernetes/services/historyService.js b/app/kubernetes/services/historyService.js deleted file mode 100644 index b00bf7f3e..000000000 --- a/app/kubernetes/services/historyService.js +++ /dev/null @@ -1,58 +0,0 @@ -import angular from 'angular'; -import PortainerError from 'Portainer/error'; - -import KubernetesHistoryHelper from 'Kubernetes/helpers/history'; -import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; - -class KubernetesHistoryService { - /* @ngInject */ - constructor($async, KubernetesReplicaSetService, KubernetesControllerRevisionService) { - this.$async = $async; - this.KubernetesReplicaSetService = KubernetesReplicaSetService; - this.KubernetesControllerRevisionService = KubernetesControllerRevisionService; - - this.getAsync = this.getAsync.bind(this); - } - - /** - * GET - */ - async getAsync(application) { - try { - const namespace = application.ResourcePool; - let rawRevisions; - - switch (application.ApplicationType) { - case KubernetesApplicationTypes.DEPLOYMENT: - rawRevisions = await this.KubernetesReplicaSetService.get(namespace); - break; - case KubernetesApplicationTypes.DAEMONSET: - rawRevisions = await this.KubernetesControllerRevisionService.get(namespace); - break; - case KubernetesApplicationTypes.STATEFULSET: - rawRevisions = await this.KubernetesControllerRevisionService.get(namespace); - break; - case KubernetesApplicationTypes.POD: - rawRevisions = []; - break; - default: - throw new PortainerError('Unable to determine which association to use for history'); - } - if (rawRevisions.length) { - const [currentRevision, revisionsList] = KubernetesHistoryHelper.getRevisions(rawRevisions, application); - application.CurrentRevision = currentRevision; - application.Revisions = revisionsList; - } - return application; - } catch (err) { - throw new PortainerError('', err); - } - } - - get(application) { - return this.$async(this.getAsync, application); - } -} - -export default KubernetesHistoryService; -angular.module('portainer.kubernetes').service('KubernetesHistoryService', KubernetesHistoryService); diff --git a/app/kubernetes/services/replicaSetService.js b/app/kubernetes/services/replicaSetService.js deleted file mode 100644 index a6f65bc06..000000000 --- a/app/kubernetes/services/replicaSetService.js +++ /dev/null @@ -1,31 +0,0 @@ -import angular from 'angular'; -import PortainerError from 'Portainer/error'; - -class KubernetesReplicaSetService { - /* @ngInject */ - constructor($async, KubernetesReplicaSets) { - this.$async = $async; - this.KubernetesReplicaSets = KubernetesReplicaSets; - - this.getAllAsync = this.getAllAsync.bind(this); - } - - /** - * GET - */ - async getAllAsync(namespace) { - try { - const data = await this.KubernetesReplicaSets(namespace).get().$promise; - return data.items; - } catch (err) { - throw new PortainerError('Unable to retrieve ReplicaSets', err); - } - } - - get(namespace) { - return this.$async(this.getAllAsync, namespace); - } -} - -export default KubernetesReplicaSetService; -angular.module('portainer.kubernetes').service('KubernetesReplicaSetService', KubernetesReplicaSetService); diff --git a/app/kubernetes/services/statefulSetService.js b/app/kubernetes/services/statefulSetService.js index 5a14c4190..4b9bd0b5b 100644 --- a/app/kubernetes/services/statefulSetService.js +++ b/app/kubernetes/services/statefulSetService.js @@ -14,7 +14,6 @@ class KubernetesStatefulSetService { this.getAllAsync = this.getAllAsync.bind(this); this.createAsync = this.createAsync.bind(this); this.patchAsync = this.patchAsync.bind(this); - this.rollbackAsync = this.rollbackAsync.bind(this); this.deleteAsync = this.deleteAsync.bind(this); } @@ -122,23 +121,6 @@ class KubernetesStatefulSetService { delete(statefulSet) { return this.$async(this.deleteAsync, statefulSet); } - - /** - * ROLLBACK - */ - async rollbackAsync(namespace, name, payload) { - try { - const params = new KubernetesCommonParams(); - params.id = name; - await this.KubernetesStatefulSets(namespace).rollback(params, payload).$promise; - } catch (err) { - throw new PortainerError('Unable to rollback statefulSet', err); - } - } - - rollback(namespace, name, payload) { - return this.$async(this.rollbackAsync, namespace, name, payload); - } } export default KubernetesStatefulSetService; diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html index 745e3211a..32bd244c3 100644 --- a/app/kubernetes/views/applications/edit/application.html +++ b/app/kubernetes/views/applications/edit/application.html @@ -86,308 +86,7 @@
- - -
- - - - - - - Create template from application - -
- - -
Accessing the application
- -
- This application is not exposing any port. -
- -
- -
-
-

This application is exposed through service(s) as below:

-
-
- - - - - - - - -
- - -
Auto-scaling
- -
- - This application does not have an autoscaling policy defined. -
- -
-
- - - - - - - - - - - - - -
Minimum instancesMaximum instances - Target CPU usage - - -
{{ ctrl.application.AutoScaler.MinReplicas }}{{ ctrl.application.AutoScaler.MaxReplicas }}{{ ctrl.application.AutoScaler.TargetCPUUtilization }}%
-
-
- - - -
- - ConfigMap or Secret -
- -
- - This application is not using any environment variable, ConfigMap or Secret. -
- - - - - - - - - - - - - - - - -
ContainerEnvironment variableValueConfigMap or Secret
- {{ container.Name }} - - - {{ envvar.valueFrom.fieldRef.fieldPath }} (init container) - {{ envvar.name }} - {{ envvar.value }} - {{ envvar.valueFrom.configMapKeyRef.key }} - {{ envvar.valueFrom.secretKeyRef.key }} - {{ envvar.valueFrom.fieldRef.fieldPath }} (downward API) - - - - - - {{ envvar.valueFrom.configMapKeyRef.name }} - {{ envvar.valueFrom.secretKeyRef.name }} -
- - - - - - - - - - - - - - - - -
ContainerConfiguration pathValueConfiguration
- {{ container.Name }} - {{ envvar.valueFrom.fieldRef.fieldPath }} (init container) - - {{ volume.fileMountPath }} - - - {{ volume.configurationKey ? volume.configurationKey : '-' }} - - {{ volume.configurationName }} -
- - - -
- - Data persistence -
- -
- - This application has no persisted folders. -
- -
-
- Data access policy: - - {{ ctrl.application.DataAccessPolicy | kubernetesApplicationDataAccessPolicyText }} - -
- - - - - - - - - - - - - -
Persisted folderPersistence
- {{ volume.MountPath }} - - {{ volume.PersistentVolumeClaimName }} - {{ volume.HostPath }} on host filesystem
- - - - - - - - - - - - - - - - - - - -
Container namePod namePersisted folderPersistence
- {{ container.Name }} - {{ envvar.valueFrom.fieldRef.fieldPath }} (init container) - {{ container.PodName }} - {{ volume.MountPath }} - - - {{ volume.PersistentVolumeClaimName + '-' + container.PodName }} - {{ volume.HostPath }} on host filesystem
- -
-
-
+
diff --git a/app/kubernetes/views/applications/edit/applicationController.js b/app/kubernetes/views/applications/edit/applicationController.js index 9d75296be..2eac442e3 100644 --- a/app/kubernetes/views/applications/edit/applicationController.js +++ b/app/kubernetes/views/applications/edit/applicationController.js @@ -15,9 +15,6 @@ import { KubernetesServiceTypes } from 'Kubernetes/models/service/models'; import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models'; import { KubernetesPodContainerTypes } from 'Kubernetes/pod/models/index'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { confirmUpdate, confirm } from '@@/modals/confirm'; -import { buildConfirmButton } from '@@/modals/utils'; -import { ModalType } from '@@/modals'; function computeTolerations(nodes, application) { const pod = application.Pods[0]; @@ -146,11 +143,6 @@ class KubernetesApplicationController { this.getApplicationAsync = this.getApplicationAsync.bind(this); this.getEvents = this.getEvents.bind(this); this.getEventsAsync = this.getEventsAsync.bind(this); - this.updateApplicationKindText = this.updateApplicationKindText.bind(this); - this.updateApplicationAsync = this.updateApplicationAsync.bind(this); - this.redeployApplicationAsync = this.redeployApplicationAsync.bind(this); - this.rollbackApplicationAsync = this.rollbackApplicationAsync.bind(this); - this.copyLoadBalancerIP = this.copyLoadBalancerIP.bind(this); } selectTab(index) { @@ -166,128 +158,10 @@ class KubernetesApplicationController { return KubernetesNamespaceHelper.isSystemNamespace(this.application.ResourcePool); } - isExternalApplication() { - return KubernetesApplicationHelper.isExternalApplication(this.application); - } - - copyLoadBalancerIP() { - this.clipboard.copyText(this.application.LoadBalancerIPAddress); - $('#copyNotificationLB').show().fadeOut(2500); - } - - copyApplicationName() { - this.clipboard.copyText(this.application.Name); - $('#copyNotificationApplicationName').show().fadeOut(2500); - } - - hasPersistedFolders() { - return this.application && this.application.PersistedFolders.length; - } - - hasVolumeConfiguration() { - return this.application && this.application.ConfigurationVolumes.length; - } - hasEventWarnings() { return this.state.eventWarningCount; } - buildIngressRuleURL(rule) { - const hostname = rule.Host ? rule.Host : rule.IP; - return 'http://' + hostname + rule.Path; - } - - portHasIngressRules(port) { - return port.IngressRules.length > 0; - } - - ruleCanBeDisplayed(rule) { - return !rule.Host && !rule.IP ? false : true; - } - - isStack() { - return this.application.StackId; - } - - /** - * ROLLBACK - */ - async rollbackApplicationAsync() { - try { - // await this.KubernetesApplicationService.rollback(this.application, this.formValues.SelectedRevision); - const revision = _.nth(this.application.Revisions, -2); - await this.KubernetesApplicationService.rollback(this.application, revision); - this.Notifications.success('Success', 'Application successfully rolled back'); - this.$state.reload(this.$state.current); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to rollback the application'); - } - } - - rollbackApplication() { - confirmUpdate('Rolling back the application to a previous configuration may cause service interruption. Do you wish to continue?', (confirmed) => { - if (confirmed) { - return this.$async(this.rollbackApplicationAsync); - } - }); - } - /** - * REDEPLOY - */ - async redeployApplicationAsync() { - const confirmed = await confirm({ - modalType: ModalType.Warn, - title: 'Are you sure?', - message: 'Redeploying the application may cause a service interruption. Do you wish to continue?', - confirmButton: buildConfirmButton('Redeploy'), - }); - if (!confirmed) { - return; - } - - try { - const promises = _.map(this.application.Pods, (item) => this.KubernetesPodService.delete(item)); - await Promise.all(promises); - this.Notifications.success('Success', 'Application successfully redeployed'); - this.$state.reload(this.$state.current); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to redeploy the application'); - } - } - - redeployApplication() { - return this.$async(this.redeployApplicationAsync); - } - - /** - * UPDATE - */ - async updateApplicationAsync() { - try { - const application = angular.copy(this.application); - application.Note = this.formValues.Note; - await this.KubernetesApplicationService.patch(this.application, application, true); - this.Notifications.success('Success', 'Application successfully updated'); - this.$state.reload(this.$state.current); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to update application'); - } - } - - updateApplication() { - return this.$async(this.updateApplicationAsync); - } - - updateApplicationKindText() { - if (this.application.ApplicationKind === this.KubernetesDeploymentTypes.GIT) { - this.state.appType = `git repository`; - } else if (this.application.ApplicationKind === this.KubernetesDeploymentTypes.CONTENT) { - this.state.appType = `manifest`; - } else if (this.application.ApplicationKind === this.KubernetesDeploymentTypes.URL) { - this.state.appType = `manifest`; - } - } - /** * EVENTS */ @@ -325,22 +199,7 @@ class KubernetesApplicationController { this.KubernetesNodeService.get(), ]); this.application = application; - if (this.application.StackId) { - this.stack = await this.StackService.stack(application.StackId); - } this.allContainers = KubernetesApplicationHelper.associateAllContainersAndApplication(application); - this.formValues.Note = this.application.Note; - this.formValues.Services = this.application.Services; - if (this.application.Note) { - this.state.expandedNote = true; - } - if (this.application.CurrentRevision) { - this.formValues.SelectedRevision = _.find(this.application.Revisions, { revision: this.application.CurrentRevision.revision }); - } - - this.state.useIngress = _.find(application.PublishedPorts, (p) => { - return this.portHasIngressRules(p); - }); this.placements = computePlacements(nodes, this.application); this.state.placementWarning = _.find(this.placements, { AcceptsApplication: true }) ? false : true; @@ -379,7 +238,6 @@ class KubernetesApplicationController { eventWarningCount: 0, placementWarning: false, expandedNote: false, - useIngress: false, useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics, publicUrl: this.endpoint.PublicURL, }; @@ -391,12 +249,8 @@ class KubernetesApplicationController { SelectedRevision: undefined, }; - const resourcePools = await this.KubernetesResourcePoolService.get(); - this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name); - await this.getApplication(); await this.getEvents(); - this.updateApplicationKindText(); this.state.viewReady = true; } diff --git a/app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.controller.js b/app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.controller.js deleted file mode 100644 index 878f96310..000000000 --- a/app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.controller.js +++ /dev/null @@ -1,30 +0,0 @@ -import _ from 'lodash-es'; - -export default class KubernetesApplicationIngressController { - /* @ngInject */ - constructor($async, KubernetesIngressService) { - this.$async = $async; - this.KubernetesIngressService = KubernetesIngressService; - } - - $onInit() { - return this.$async(async () => { - this.hasIngress; - this.applicationIngress = []; - const ingresses = await this.KubernetesIngressService.get(this.application.ResourcePool); - const services = this.application.Services; - - _.forEach(services, (service) => { - _.forEach(ingresses, (ingress) => { - _.forEach(ingress.Paths, (path) => { - if (path.ServiceName === service.metadata.name) { - path.Secure = ingress.TLS && ingress.TLS.filter((tls) => tls.hosts && tls.hosts.includes(path.Host)).length > 0; - this.applicationIngress.push(path); - this.hasIngress = true; - } - }); - }); - }); - }); - } -} diff --git a/app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.html b/app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.html deleted file mode 100644 index 09a460b8b..000000000 --- a/app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.html +++ /dev/null @@ -1,28 +0,0 @@ -
- - - - - - - - - - - - - - - - - - - -
Ingress nameService nameHostPortPathHTTP Route
{{ - ingress.IngressName - }}{{ ingress.ServiceName }}{{ ingress.Host }}{{ ingress.Port }}{{ ingress.Path }}{{ ingress.Host }}{{ ingress.Path }}
-
diff --git a/app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.js b/app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.js deleted file mode 100644 index d9da327b4..000000000 --- a/app/kubernetes/views/applications/edit/components/ingress-table/ingress-table.js +++ /dev/null @@ -1,11 +0,0 @@ -import angular from 'angular'; -import controller from './ingress-table.controller'; - -angular.module('portainer.kubernetes').component('kubernetesApplicationIngressTable', { - templateUrl: './ingress-table.html', - controller, - bindings: { - application: '<', - publicUrl: '<', - }, -}); diff --git a/app/kubernetes/views/applications/edit/components/services-table/services-table.html b/app/kubernetes/views/applications/edit/components/services-table/services-table.html deleted file mode 100644 index d60b380e4..000000000 --- a/app/kubernetes/views/applications/edit/components/services-table/services-table.html +++ /dev/null @@ -1,62 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - -
Service nameTypeCluster IPExternal IPContainer portService port(s)
{{ service.metadata.name }}{{ service.spec.type }}{{ service.spec.clusterIP }} - -
- {{ service.spec.externalIP ? service.spec.externalIP : 'pending...' }} -
-
{{ service.spec.externalIP ? service.spec.externalIP : '-' }} -
{{ port.targetPort }}
-
-
- - - - {{ port.port }} - - {{ port.nodePort ? ':' : '' }} - {{ port.nodePort }}/{{ port.protocol }} - - -
- - {{ port.port }} - - {{ port.nodePort ? ':' : '' }} - {{ port.nodePort }}/{{ port.protocol }} -
-
-
-
diff --git a/app/kubernetes/views/applications/edit/components/services-table/services-table.js b/app/kubernetes/views/applications/edit/components/services-table/services-table.js deleted file mode 100644 index 4b82cfbb3..000000000 --- a/app/kubernetes/views/applications/edit/components/services-table/services-table.js +++ /dev/null @@ -1,10 +0,0 @@ -import angular from 'angular'; - -angular.module('portainer.kubernetes').component('kubernetesApplicationServicesTable', { - templateUrl: './services-table.html', - bindings: { - services: '<', - application: '<', - publicUrl: '<', - }, -}); diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js index 8fc48e276..2c2146224 100644 --- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js @@ -1,6 +1,6 @@ import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; -import { confirmStackUpdate } from '@/react/docker/stacks/common/confirm-stack-update'; +import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index 1dca422af..49e1836a6 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -2,9 +2,9 @@ import { ResourceControlType } from '@/react/portainer/access-control/types'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { getEnvironments } from '@/react/portainer/environments/environment.service'; -import { StackStatus, StackType } from '@/react/docker/stacks/types'; +import { StackStatus, StackType } from '@/react/common/stacks/types'; import { extractContainerNames } from '@/portainer/helpers/stackHelper'; -import { confirmStackUpdate } from '@/react/docker/stacks/common/confirm-stack-update'; +import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; import { confirm, confirmDelete, confirmWebEditorDiscard } from '@@/modals/confirm'; import { ModalType } from '@@/modals'; import { buildConfirmButton } from '@@/modals/utils'; diff --git a/app/react/docker/stacks/CreateView/.keep b/app/react/common/stacks/CreateView/.keep similarity index 100% rename from app/react/docker/stacks/CreateView/.keep rename to app/react/common/stacks/CreateView/.keep diff --git a/app/react/docker/stacks/ItemView/.keep b/app/react/common/stacks/ItemView/.keep similarity index 100% rename from app/react/docker/stacks/ItemView/.keep rename to app/react/common/stacks/ItemView/.keep diff --git a/app/react/docker/stacks/ItemView/StackContainersDatatable.tsx b/app/react/common/stacks/ItemView/StackContainersDatatable.tsx similarity index 95% rename from app/react/docker/stacks/ItemView/StackContainersDatatable.tsx rename to app/react/common/stacks/ItemView/StackContainersDatatable.tsx index aca08269c..253079523 100644 --- a/app/react/docker/stacks/ItemView/StackContainersDatatable.tsx +++ b/app/react/common/stacks/ItemView/StackContainersDatatable.tsx @@ -17,8 +17,8 @@ import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu'; import { TableSettingsProvider } from '@@/datatables/useTableSettings'; import { useTableState } from '@@/datatables/useTableState'; -import { useContainers } from '../../containers/queries/containers'; -import { RowProvider } from '../../containers/ListView/ContainersDatatable/RowContext'; +import { useContainers } from '../../../docker/containers/queries/containers'; +import { RowProvider } from '../../../docker/containers/ListView/ContainersDatatable/RowContext'; const storageKey = 'stack-containers'; const settingsStore = createStore(storageKey); diff --git a/app/react/docker/stacks/ListView/.keep b/app/react/common/stacks/ListView/.keep similarity index 100% rename from app/react/docker/stacks/ListView/.keep rename to app/react/common/stacks/ListView/.keep diff --git a/app/react/docker/stacks/common/confirm-stack-update.ts b/app/react/common/stacks/common/confirm-stack-update.ts similarity index 100% rename from app/react/docker/stacks/common/confirm-stack-update.ts rename to app/react/common/stacks/common/confirm-stack-update.ts diff --git a/app/react/common/stacks/readme.md b/app/react/common/stacks/readme.md new file mode 100644 index 000000000..020ff1415 --- /dev/null +++ b/app/react/common/stacks/readme.md @@ -0,0 +1 @@ +Stacks are placed in the `/app/react/common` folder, because they are used by both Kubernetes and Docker environments and are saved locally to the Portainer database. diff --git a/app/react/common/stacks/stack.service.ts b/app/react/common/stacks/stack.service.ts new file mode 100644 index 000000000..8321e8e28 --- /dev/null +++ b/app/react/common/stacks/stack.service.ts @@ -0,0 +1,25 @@ +import { useQuery } from 'react-query'; + +import axios from '@/portainer/services/axios'; +import { withError } from '@/react-tools/react-query'; + +import { StackFile, StackId } from './types'; + +const queryKeys = { + stackFile: (stackId?: StackId) => ['stacks', stackId, 'file'], +}; + +export function useStackFile(stackId?: StackId) { + return useQuery(queryKeys.stackFile(stackId), () => getStackFile(stackId), { + ...withError('Unable to retrieve stack'), + enabled: !!stackId, + }); +} + +async function getStackFile(stackId?: StackId) { + if (!stackId) { + return Promise.resolve(undefined); + } + const { data } = await axios.get(`/stacks/${stackId}/file`); + return data; +} diff --git a/app/react/docker/stacks/types.ts b/app/react/common/stacks/types.ts similarity index 87% rename from app/react/docker/stacks/types.ts rename to app/react/common/stacks/types.ts index 062028962..953702918 100644 --- a/app/react/docker/stacks/types.ts +++ b/app/react/common/stacks/types.ts @@ -23,3 +23,7 @@ export enum StackStatus { Active = 1, Inactive, } + +export type StackFile = { + StackFileContent: string; +}; diff --git a/app/react/kubernetes/ServicesView/service.ts b/app/react/kubernetes/ServicesView/service.ts index 708010d34..48f553949 100644 --- a/app/react/kubernetes/ServicesView/service.ts +++ b/app/react/kubernetes/ServicesView/service.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import { compact } from 'lodash'; +import { ServiceList } from 'kubernetes-types/core/v1'; import { withError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; @@ -11,10 +12,11 @@ import { getNamespaces } from '../namespaces/service'; import { Service } from './types'; export const queryKeys = { - list: (environmentId: EnvironmentId) => + clusterServices: (environmentId: EnvironmentId) => ['environments', environmentId, 'kubernetes', 'services'] as const, }; +// get a list of services for a specific namespace from the Portainer API async function getServices( environmentId: EnvironmentId, namespace: string, @@ -37,7 +39,7 @@ async function getServices( export function useServices(environmentId: EnvironmentId) { return useQuery( - queryKeys.list(environmentId), + queryKeys.clusterServices(environmentId), async () => { const namespaces = await getNamespaces(environmentId); const settledServicesPromise = await Promise.allSettled( @@ -53,12 +55,26 @@ export function useServices(environmentId: EnvironmentId) { ); } +// getNamespaceServices is used to get a list of services for a specific namespace directly from the Kubernetes API +export async function getNamespaceServices( + environmentId: EnvironmentId, + namespace: string, + queryParams?: Record +) { + const { data: services } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services`, + { + params: queryParams, + } + ); + return services.items; +} + export function useMutationDeleteServices(environmentId: EnvironmentId) { const queryClient = useQueryClient(); return useMutation(deleteServices, { onSuccess: () => - // use the exact same query keys as the useServices hook to invalidate the services list - queryClient.invalidateQueries(queryKeys.list(environmentId)), + queryClient.invalidateQueries(queryKeys.clusterServices(environmentId)), ...withError('Unable to delete service(s)'), }); } diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationAutoScalingTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationAutoScalingTable.tsx new file mode 100644 index 000000000..8a510db96 --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationAutoScalingTable.tsx @@ -0,0 +1,74 @@ +import { Move } from 'lucide-react'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { Icon } from '@@/Icon'; +import { TextTip } from '@@/Tip/TextTip'; +import { Tooltip } from '@@/Tip/Tooltip'; + +import { Application } from '../../types'; +import { useApplicationHorizontalPodAutoscalers } from '../../application.queries'; + +type Props = { + environmentId: EnvironmentId; + namespace: string; + appName: string; + app?: Application; +}; + +export function ApplicationAutoScalingTable({ + environmentId, + namespace, + appName, + app, +}: Props) { + const { data: appAutoScalar } = useApplicationHorizontalPodAutoscalers( + environmentId, + namespace, + appName, + app + ); + + return ( + <> +
+ + Auto-scaling +
+ {!appAutoScalar && ( + + This application does not have an autoscaling policy defined. + + )} + {appAutoScalar && ( +
+ + + + + + + + + + + + + +
Minimum instancesMaximum instances +
+ Target CPU usage + +
+
+ {appAutoScalar.spec?.minReplicas} + + {appAutoScalar.spec?.maxReplicas} + + {appAutoScalar.spec?.targetCPUUtilizationPercentage}% +
+
+ )} + + ); +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx new file mode 100644 index 000000000..7714bdc44 --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx @@ -0,0 +1,142 @@ +import { Pencil, Plus } from 'lucide-react'; +import { useCurrentStateAndParams } from '@uirouter/react'; +import { Pod } from 'kubernetes-types/core/v1'; + +import { Authorized } from '@/react/hooks/useUser'; +import { useStackFile } from '@/react/common/stacks/stack.service'; + +import { Widget, WidgetBody } from '@@/Widget'; +import { Button } from '@@/buttons'; +import { Link } from '@@/Link'; +import { Icon } from '@@/Icon'; + +import { + useApplication, + useApplicationServices, +} from '../../application.queries'; +import { isSystemNamespace } from '../../../namespaces/utils'; +import { applicationIsKind, isExternalApplication } from '../../utils'; +import { appStackIdLabel } from '../../constants'; + +import { RestartApplicationButton } from './RestartApplicationButton'; +import { RedeployApplicationButton } from './RedeployApplicationButton'; +import { RollbackApplicationButton } from './RollbackApplicationButton'; +import { ApplicationServicesTable } from './ApplicationServicesTable'; +import { ApplicationIngressesTable } from './ApplicationIngressesTable'; +import { ApplicationAutoScalingTable } from './ApplicationAutoScalingTable'; +import { ApplicationEnvVarsTable } from './ApplicationEnvVarsTable'; +import { ApplicationVolumeConfigsTable } from './ApplicationVolumeConfigsTable'; +import { ApplicationPersistentDataTable } from './ApplicationPersistentDataTable'; + +export function ApplicationDetailsWidget() { + const stateAndParams = useCurrentStateAndParams(); + const { + params: { + namespace, + name, + 'resource-type': resourceType, + endpointId: environmentId, + }, + } = stateAndParams; + + // get app info + const appQuery = useApplication(environmentId, namespace, name, resourceType); + const app = appQuery.data; + const externalApp = app && isExternalApplication(app); + const appStackId = Number(app?.metadata?.labels?.[appStackIdLabel]); + const appStackFileQuery = useStackFile(appStackId); + const { data: appServices } = useApplicationServices( + environmentId, + namespace, + name, + app + ); + + return ( + + + {!isSystemNamespace(namespace) && ( +
+ + + + + + {!applicationIsKind('Pod', app) && ( + <> + + + + )} + {!externalApp && ( + + )} + {appStackFileQuery.data && ( + + + + )} +
+ )} + + + + + + +
+
+ ); +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx new file mode 100644 index 000000000..d82e4c13a --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx @@ -0,0 +1,172 @@ +import { EnvVar, Pod } from 'kubernetes-types/core/v1'; +import { Asterisk, File, Key } from 'lucide-react'; + +import { Icon } from '@@/Icon'; +import { TextTip } from '@@/Tip/TextTip'; +import { Link } from '@@/Link'; + +import { Application } from '../../types'; +import { applicationIsKind } from '../../utils'; + +type Props = { + namespace: string; + app?: Application; +}; + +export function ApplicationEnvVarsTable({ namespace, app }: Props) { + const appEnvVars = getApplicationEnvironmentVariables(app); + + return ( + <> +
+ + Configuration +
+ {appEnvVars.length === 0 && ( + + This application is not using any environment variable or + configuration. + + )} + {appEnvVars.length > 0 && ( + + + + + + + + + {appEnvVars.map((envVar, index) => ( + + + + + + + ))} + +
ContainerEnvironment variableValueConfiguration
+ {envVar.containerName} + {envVar.isInitContainer && ( + + + {envVar.valueFrom?.fieldRef?.fieldPath} ( + + init container + + ) + + )} + {envVar.name} + {envVar.value && {envVar.value}} + {envVar.valueFrom?.fieldRef?.fieldPath && ( + + + {envVar.valueFrom.fieldRef.fieldPath} ( + + downward API + + ) + + )} + {envVar.valueFrom?.secretKeyRef?.key && ( + + + {envVar.valueFrom.secretKeyRef.key} + + )} + {envVar.valueFrom?.configMapKeyRef?.key && ( + + + {envVar.valueFrom.configMapKeyRef.key} + + )} + {!envVar.value && !envVar.valueFrom && -} + + {!envVar.valueFrom?.configMapKeyRef?.name && + !envVar.valueFrom?.secretKeyRef?.name && -} + {envVar.valueFrom?.configMapKeyRef && ( + + + + {envVar.valueFrom.configMapKeyRef.name} + + + )} + {envVar.valueFrom?.secretKeyRef && ( + + + + {envVar.valueFrom.secretKeyRef.name} + + + )} +
+ )} + + ); +} + +interface ContainerEnvVar extends EnvVar { + containerName: string; + isInitContainer: boolean; +} + +function getApplicationEnvironmentVariables( + app?: Application +): ContainerEnvVar[] { + if (!app) { + return []; + } + + const podSpec = applicationIsKind('Pod', app) + ? app.spec + : app.spec?.template?.spec; + const appContainers = podSpec?.containers || []; + const appInitContainers = podSpec?.initContainers || []; + + // get all the environment variables for each container + const appContainersEnvVars = + appContainers?.flatMap( + (container) => + container?.env?.map((envVar) => ({ + ...envVar, + containerName: container.name, + isInitContainer: false, + })) || [] + ) || []; + const appInitContainersEnvVars = + appInitContainers?.flatMap( + (container) => + container?.env?.map((envVar) => ({ + ...envVar, + containerName: container.name, + isInitContainer: true, + })) || [] + ) || []; + + return [...appContainersEnvVars, ...appInitContainersEnvVars]; +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationIngressesTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationIngressesTable.tsx new file mode 100644 index 000000000..9350ee209 --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationIngressesTable.tsx @@ -0,0 +1,125 @@ +import { Service } from 'kubernetes-types/core/v1'; +import { useMemo } from 'react'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { useIngresses } from '@/react/kubernetes/ingresses/queries'; +import { Ingress } from '@/react/kubernetes/ingresses/types'; +import { Authorized } from '@/react/hooks/useUser'; + +import { Link } from '@@/Link'; + +type Props = { + environmentId: EnvironmentId; + namespace: string; + appServices?: Service[]; +}; + +export function ApplicationIngressesTable({ + environmentId, + namespace, + appServices, +}: Props) { + const namespaceIngresses = useIngresses(environmentId, [namespace]); + // getIngressPathsForAppServices could be expensive, so memoize it + const ingressPathsForAppServices = useMemo( + () => getIngressPathsForAppServices(namespaceIngresses.data, appServices), + [namespaceIngresses.data, appServices] + ); + + if (!ingressPathsForAppServices.length) { + return null; + } + + return ( + + + + + + + + + + + {ingressPathsForAppServices.map((ingressPath, index) => ( + + + + + + + + + ))} + +
Ingress nameService nameHostPortPathHTTP Route
+ + + {ingressPath.ingressName} + + + {ingressPath.serviceName}{ingressPath.host}{ingressPath.port}{ingressPath.path} + + {ingressPath.host} + {ingressPath.path} + +
+ ); +} + +type IngressPath = { + ingressName: string; + serviceName: string; + port: number; + secure: boolean; + host: string; + path: string; +}; + +function getIngressPathsForAppServices( + ingresses?: Ingress[], + services?: Service[] +): IngressPath[] { + if (!ingresses || !services) { + return []; + } + const matchingIngressesPaths = ingresses.flatMap((ingress) => { + // for each ingress get an array of ingress paths that match the app services + const matchingIngressPaths = ingress.Paths.filter((path) => + services?.some((service) => { + const servicePorts = service.spec?.ports?.map((port) => port.port); + // include the ingress if the ingress path has a matching service name and port + return ( + path.ServiceName === service.metadata?.name && + servicePorts?.includes(path.Port) + ); + }) + ).map((path) => { + const secure = + (ingress.TLS && + ingress.TLS.filter( + (tls) => tls.Hosts && tls.Hosts.includes(path.Host) + ).length > 0) ?? + false; + return { + ingressName: ingress.Name, + serviceName: path.ServiceName, + port: path.Port, + secure, + host: path.Host, + path: path.Path, + }; + }); + return matchingIngressPaths; + }); + return matchingIngressesPaths; +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationPersistentDataTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationPersistentDataTable.tsx new file mode 100644 index 000000000..0f5944c09 --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationPersistentDataTable.tsx @@ -0,0 +1,268 @@ +import { useMemo } from 'react'; +import { Asterisk, Box, Boxes, Database } from 'lucide-react'; +import { Container, Pod, Volume } from 'kubernetes-types/core/v1'; +import { StatefulSet } from 'kubernetes-types/apps/v1'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { Icon } from '@@/Icon'; +import { TextTip } from '@@/Tip/TextTip'; +import { Tooltip } from '@@/Tip/Tooltip'; +import { Link } from '@@/Link'; + +import { Application } from '../../types'; +import { applicationIsKind } from '../../utils'; +import { useApplicationPods } from '../../application.queries'; + +type Props = { + environmentId: EnvironmentId; + namespace: string; + appName: string; + app?: Application; +}; + +export function ApplicationPersistentDataTable({ + namespace, + app, + environmentId, + appName, +}: Props) { + const { data: pods } = useApplicationPods( + environmentId, + namespace, + appName, + app + ); + const persistedFolders = useMemo( + () => getPersistedFolders(app, pods), + [app, pods] + ); + const dataAccessPolicy = getDataAccessPolicy(app); + + return ( + <> +
+ + Data persistence +
+ {!persistedFolders.length && ( + + This application has no persisted folders. + + )} + {persistedFolders.length > 0 && ( + <> +
+ Data access policy: + {dataAccessPolicy === 'isolated' && ( + <> + + Isolated + + + )} + {dataAccessPolicy === 'shared' && ( + <> + + Shared + + + )} +
+ {dataAccessPolicy === 'isolated' && ( + + + + + + + + + + + {persistedFolders.map((persistedFolder, index) => ( + + + + + + + ))} + +
Container namePod namePersisted folderPersistence
+ {persistedFolder.volumeMount.container.name} + {persistedFolder.isContainerInit && ( + + ( + + init container + + ) + + )} + {persistedFolder.volumeMount?.pod?.metadata?.name}{persistedFolder.volumeMount.mountPath} + {persistedFolder.volume.persistentVolumeClaim && ( + + + {`${persistedFolder.volume.persistentVolumeClaim.claimName}-${persistedFolder.volumeMount?.pod?.metadata?.name}`} + + )} + {persistedFolder.volume.hostPath && + `${persistedFolder.volume.hostPath.path} on host filesystem`} +
+ )} + {dataAccessPolicy === 'shared' && ( + + + + + + + + + {persistedFolders.map((persistedFolder, index) => ( + + + + + ))} + +
Persisted folderPersistence
+ {persistedFolder.volumeMount.mountPath} + + {persistedFolder.volume.persistentVolumeClaim && ( + + + { + persistedFolder.volume.persistentVolumeClaim + .claimName + } + + )} + {persistedFolder.volume.hostPath && + `${persistedFolder.volume.hostPath.path} on host filesystem`} +
+ )} + + )} + + ); +} + +function getDataAccessPolicy(app?: Application) { + if (!app || applicationIsKind('Pod', app)) { + return 'none'; + } + if (applicationIsKind('StatefulSet', app)) { + return 'isolated'; + } + return 'shared'; +} + +function getPodsMatchingContainer(pods: Pod[], container: Container) { + const matchingPods = pods.filter((pod) => { + const podContainers = pod.spec?.containers || []; + const podInitContainers = pod.spec?.initContainers || []; + const podAllContainers = [...podContainers, ...podInitContainers]; + return podAllContainers.some( + (podContainer) => + podContainer.name === container.name && + podContainer.image === container.image + ); + }); + return matchingPods; +} + +function getPersistedFolders(app?: Application, pods?: Pod[]) { + if (!app || !pods) { + return []; + } + + const podSpec = applicationIsKind('Pod', app) + ? app.spec + : app.spec?.template?.spec; + + const appVolumes = podSpec?.volumes || []; + const appVolumeClaimVolumes = getVolumeClaimTemplates(app, appVolumes); + const appAllVolumes = [...appVolumes, ...appVolumeClaimVolumes]; + + const appContainers = podSpec?.containers || []; + const appInitContainers = podSpec?.initContainers || []; + const appAllContainers = [...appContainers, ...appInitContainers]; + + // for each volume, find the volumeMounts that match it + const persistedFolders = appAllVolumes.flatMap((volume) => { + if (volume.persistentVolumeClaim || volume.hostPath) { + const volumeMounts = appAllContainers.flatMap((container) => { + const matchingPods = getPodsMatchingContainer(pods, container); + return ( + container.volumeMounts?.flatMap( + (containerVolumeMount) => + matchingPods.map((pod) => ({ + ...containerVolumeMount, + container, + pod, + })) || [] + ) || [] + ); + }); + const uniqueMatchingVolumeMounts = volumeMounts.filter( + (volumeMount, index, self) => + self.indexOf(volumeMount) === index && // remove volumeMounts with duplicate names + volumeMount.name === volume.name // remove volumeMounts that don't match the volume + ); + return uniqueMatchingVolumeMounts.map((volumeMount) => ({ + volume, + volumeMount, + isContainerInit: appInitContainers.some( + (container) => container.name === volumeMount.container.name + ), + })); + } + return []; + }); + return persistedFolders; +} + +function getVolumeClaimTemplates(app: Application, volumes: Volume[]) { + if ( + applicationIsKind('StatefulSet', app) && + app.spec?.volumeClaimTemplates + ) { + const volumeClaimTemplates: Volume[] = app.spec.volumeClaimTemplates.map( + (vc) => ({ + name: vc.metadata?.name || '', + persistentVolumeClaim: { claimName: vc.metadata?.name || '' }, + }) + ); + const newPVC = volumeClaimTemplates.filter( + (vc) => + !volumes.find( + (v) => + v.persistentVolumeClaim?.claimName === + vc.persistentVolumeClaim?.claimName + ) + ); + return newPVC; + } + return []; +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationServicesTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationServicesTable.tsx new file mode 100644 index 000000000..8fe49d507 --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationServicesTable.tsx @@ -0,0 +1,130 @@ +import { Service } from 'kubernetes-types/core/v1'; +import { ExternalLink } from 'lucide-react'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { useEnvironment } from '@/react/portainer/environments/queries'; + +import { Icon } from '@@/Icon'; +import { TextTip } from '@@/Tip/TextTip'; + +type Props = { + environmentId: EnvironmentId; + appServices?: Service[]; +}; + +export function ApplicationServicesTable({ + environmentId, + appServices, +}: Props) { + const { data: environment } = useEnvironment(environmentId); + return ( + <> +
+ + Accessing the application +
+ {appServices && appServices.length === 0 && ( + + This application is not exposing any port. + + )} + {appServices && appServices.length > 0 && ( + <> + + This application is exposed through service(s) as below: + + + + + + + + + + + + {appServices.map((service) => ( + + + + + {service.spec?.type === 'LoadBalancer' && ( + + )} + {service.spec?.type !== 'LoadBalancer' && ( + + )} + + + + ))} + +
Service nameTypeCluster IPExternal IPContainer portService port(s)
{service.metadata?.name}{service.spec?.type}{service.spec?.clusterIP} + {service.status?.loadBalancer?.ingress?.[0] && + service.spec?.ports?.[0] && ( + + + + Access + + + )} + {!service.status?.loadBalancer?.ingress && ( +
+ {service.spec.externalIPs?.[0] + ? service.spec.externalIPs[0] + : 'pending...'} +
+ )} +
+ {service.spec?.externalIPs?.[0] + ? service.spec.externalIPs[0] + : '-'} + + {service.spec?.ports?.map((port) => ( +
{port.targetPort}
+ ))} +
+ {service.spec?.ports?.map((port) => ( +
+ {environment?.PublicURL && port.nodePort && ( + + + + {port.port} + + {port.nodePort ? ' : ' : ''} + + {port.nodePort}/{port.protocol} + + + )} + {!environment?.PublicURL && ( +
+ + {port.port} + + {port.nodePort ? ' : ' : ''} + + {port.nodePort}/{port.protocol} + +
+ )} +
+ ))} +
+ + )} + + ); +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx new file mode 100644 index 000000000..1609c6e1e --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx @@ -0,0 +1,148 @@ +import { KeyToPath, Pod } from 'kubernetes-types/core/v1'; +import { Asterisk, Plus } from 'lucide-react'; + +import { Icon } from '@@/Icon'; +import { Link } from '@@/Link'; + +import { Application } from '../../types'; +import { applicationIsKind } from '../../utils'; + +type Props = { + namespace: string; + app?: Application; +}; + +export function ApplicationVolumeConfigsTable({ namespace, app }: Props) { + const containerVolumeConfigs = getApplicationVolumeConfigs(app); + + if (containerVolumeConfigs.length === 0) { + return null; + } + + return ( + + + + + + + + + {containerVolumeConfigs.map( + ( + { + containerVolumeMount, + isInitContainer, + containerName, + item, + volumeConfigName, + }, + index + ) => ( + + + + + + + ) + )} + +
ContainerConfiguration pathValueConfiguration
+ {containerName} + {isInitContainer && ( + + ( + + init container + + ) + + )} + + {item.path + ? `${containerVolumeMount?.mountPath}/${item.path}` + : `${containerVolumeMount?.mountPath}`} + + {item.key && ( +
+ + {item.key} +
+ )} + {!item.key && '-'} +
+ {volumeConfigName && ( + + + {volumeConfigName} + + )} + {!volumeConfigName && '-'} +
+ ); +} + +// getApplicationVolumeConfigs returns a list of volume configs / secrets for each container and each item within the matching volume +function getApplicationVolumeConfigs(app?: Application) { + if (!app) { + return []; + } + + const podSpec = applicationIsKind('Pod', app) + ? app.spec + : app.spec?.template?.spec; + const appContainers = podSpec?.containers || []; + const appInitContainers = podSpec?.initContainers || []; + const appVolumes = podSpec?.volumes || []; + const allContainers = [...appContainers, ...appInitContainers]; + + const appVolumeConfigs = allContainers.flatMap((container) => { + // for each container, get the volume mount paths + const matchingVolumes = appVolumes + // filter app volumes by config map or secret + .filter((volume) => volume.configMap || volume.secret) + .flatMap((volume) => { + // flatten by volume items if there are any + const volConfigMapItems = + volume.configMap?.items || volume.secret?.items || []; + const volumeConfigName = + volume.configMap?.name || volume.secret?.secretName; + const containerVolumeMount = container.volumeMounts?.find( + (volumeMount) => volumeMount.name === volume.name + ); + if (volConfigMapItems.length === 0) { + return [ + { + volumeConfigName, + containerVolumeMount, + containerName: container.name, + isInitContainer: appInitContainers.includes(container), + item: {} as KeyToPath, + }, + ]; + } + // if there are items, return a volume config for each item + return volConfigMapItems.map((item) => ({ + volumeConfigName, + containerVolumeMount, + containerName: container.name, + isInitContainer: appInitContainers.includes(container), + item, + })); + }) + // only return the app volumes where the container volumeMounts include the volume name (from map step above) + .filter((volume) => volume.containerVolumeMount); + return matchingVolumes; + }); + + return appVolumeConfigs; +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RedeployApplicationButton.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RedeployApplicationButton.tsx new file mode 100644 index 000000000..93d413e70 --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RedeployApplicationButton.tsx @@ -0,0 +1,101 @@ +import { RotateCw } from 'lucide-react'; +import { Pod } from 'kubernetes-types/core/v1'; +import { useRouter } from '@uirouter/react'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { notifySuccess, notifyError } from '@/portainer/services/notifications'; +import { Authorized } from '@/react/hooks/useUser'; + +import { confirm } from '@@/modals/confirm'; +import { ModalType } from '@@/modals'; +import { buildConfirmButton } from '@@/modals/utils'; +import { Button } from '@@/buttons'; +import { Icon } from '@@/Icon'; + +import { useRedeployApplicationMutation } from '../../application.queries'; +import { Application } from '../../types'; +import { + applicationIsKind, + matchLabelsToLabelSelectorValue, +} from '../../utils'; + +type Props = { + environmentId: EnvironmentId; + namespace: string; + appName: string; + app?: Application; +}; + +export function RedeployApplicationButton({ + environmentId, + namespace, + appName, + app, +}: Props) { + const router = useRouter(); + const redeployAppMutation = useRedeployApplicationMutation( + environmentId, + namespace, + appName + ); + + return ( + + + + ); + + async function redeployApplication() { + // validate + if (!app || applicationIsKind('Pod', app)) { + return; + } + try { + if (!app?.spec?.selector?.matchLabels) { + throw new Error( + `Application has no 'matchLabels' selector to redeploy pods.` + ); + } + } catch (error) { + notifyError('Failure', error as Error); + return; + } + + // confirm the action + const confirmed = await confirm({ + title: 'Are you sure?', + modalType: ModalType.Warn, + confirmButton: buildConfirmButton('Redeploy'), + message: + 'Redeploying terminates and restarts the application, which will cause service interruption. Do you wish to continue?', + }); + if (!confirmed) { + return; + } + + // using the matchlabels object, delete the associated pods with redeployAppMutation + const labelSelector = matchLabelsToLabelSelectorValue( + app?.spec?.selector?.matchLabels + ); + redeployAppMutation.mutateAsync( + { labelSelector }, + { + onSuccess: () => { + notifySuccess('Success', 'Application successfully redeployed'); + router.stateService.reload(); + }, + } + ); + } +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RestartApplicationButton.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RestartApplicationButton.tsx new file mode 100644 index 000000000..18d2bfa7b --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RestartApplicationButton.tsx @@ -0,0 +1,19 @@ +import { RefreshCw } from 'lucide-react'; + +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +import { BETeaserButton } from '@@/BETeaserButton'; + +export function RestartApplicationButton() { + return ( + + ); +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RollbackApplicationButton.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RollbackApplicationButton.tsx new file mode 100644 index 000000000..6baba4f08 --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RollbackApplicationButton.tsx @@ -0,0 +1,130 @@ +import { Pod } from 'kubernetes-types/core/v1'; +import { RotateCcw } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; + +import { Authorized } from '@/react/hooks/useUser'; +import { notifySuccess, notifyError } from '@/portainer/services/notifications'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { Button } from '@@/buttons'; +import { Icon } from '@@/Icon'; +import { confirm } from '@@/modals/confirm'; +import { ModalType } from '@@/modals'; +import { buildConfirmButton } from '@@/modals/utils'; + +import { + useApplicationRevisionList, + usePatchApplicationMutation, +} from '../../application.queries'; +import { + applicationIsKind, + getRollbackPatchPayload, + matchLabelsToLabelSelectorValue, +} from '../../utils'; +import { Application } from '../../types'; +import { appDeployMethodLabel } from '../../constants'; + +type Props = { + environmentId: EnvironmentId; + namespace: string; + appName: string; + app?: Application; +}; + +export function RollbackApplicationButton({ + environmentId, + namespace, + appName, + app, +}: Props) { + const router = useRouter(); + const labelSelector = applicationIsKind('Pod', app) + ? '' + : matchLabelsToLabelSelectorValue(app?.spec?.selector?.matchLabels); + const appRevisionListQuery = useApplicationRevisionList( + environmentId, + namespace, + appName, + app?.metadata?.uid, + labelSelector, + app?.kind + ); + const appRevisionList = appRevisionListQuery.data; + const appRevisions = appRevisionList?.items; + const appDeployMethod = + app?.metadata?.labels?.[appDeployMethodLabel] || 'application form'; + + const patchAppMutation = usePatchApplicationMutation( + environmentId, + namespace, + appName + ); + + return ( + + + + ); + + async function rollbackApplication() { + // exit early if the application is a pod or there are no revisions + if ( + !app?.kind || + applicationIsKind('Pod', app) || + !appRevisionList?.items?.length + ) { + return; + } + + // confirm the action + const confirmed = await confirm({ + title: 'Are you sure?', + modalType: ModalType.Warn, + confirmButton: buildConfirmButton('Rollback'), + message: + 'Rolling back the application to a previous configuration may cause service interruption. Do you wish to continue?', + }); + if (!confirmed) { + return; + } + + try { + const patch = getRollbackPatchPayload(app, appRevisionList); + patchAppMutation.mutateAsync( + { appKind: app.kind, patch }, + { + onSuccess: () => { + notifySuccess('Success', 'Application successfully rolled back'); + router.stateService.reload(); + }, + onError: (error) => + notifyError( + 'Failure', + error as Error, + 'Unable to rollback the application' + ), + } + ); + } catch (error) { + notifyError('Failure', error as Error); + } + } +} diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/index.ts b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/index.ts new file mode 100644 index 000000000..2f2d276b1 --- /dev/null +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/index.ts @@ -0,0 +1 @@ +export { ApplicationDetailsWidget } from './ApplicationDetailsWidget'; diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx index e657cd440..056389acc 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx @@ -32,7 +32,7 @@ import { useApplication, usePatchApplicationMutation, } from '../application.queries'; -import { Application } from '../types'; +import { Application, ApplicationPatch } from '../types'; export function ApplicationSummaryWidget() { const stateAndParams = useCurrentStateAndParams(); @@ -263,14 +263,18 @@ export function ApplicationSummaryWidget() { ); async function patchApplicationNote() { - const path = `/metadata/annotations/${appNoteAnnotation}`; - const value = applicationNoteFormValues; + const patch: ApplicationPatch = [ + { + op: 'replace', + path: `/metadata/annotations/${appNoteAnnotation}`, + value: 'applicationNoteFormValues', + }, + ]; if (application?.kind) { try { await patchApplicationMutation.mutateAsync({ appKind: application.kind, - path, - value, + patch, }); notifySuccess('Success', 'Application successfully updated'); } catch (error) { diff --git a/app/react/kubernetes/applications/DetailsView/index.ts b/app/react/kubernetes/applications/DetailsView/index.ts index a9e2fb00d..1ee252868 100644 --- a/app/react/kubernetes/applications/DetailsView/index.ts +++ b/app/react/kubernetes/applications/DetailsView/index.ts @@ -1 +1,2 @@ export { ApplicationSummaryWidget } from './ApplicationSummaryWidget'; +export { ApplicationDetailsWidget } from './ApplicationDetailsWidget/ApplicationDetailsWidget'; diff --git a/app/react/kubernetes/applications/application.queries.ts b/app/react/kubernetes/applications/application.queries.ts index dc02bfcbf..b0332c0a8 100644 --- a/app/react/kubernetes/applications/application.queries.ts +++ b/app/react/kubernetes/applications/application.queries.ts @@ -1,14 +1,21 @@ import { useMutation, useQuery } from 'react-query'; +import { Pod } from 'kubernetes-types/core/v1'; import { queryClient, withError } from '@/react-tools/react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; +import { getNamespaceServices } from '../ServicesView/service'; + import { getApplicationsForCluster, getApplication, patchApplication, + getApplicationRevisionList, } from './application.service'; -import { AppKind } from './types'; +import type { AppKind, Application, ApplicationPatch } from './types'; +import { deletePod, getNamespacePods } from './pod.service'; +import { getNamespaceHorizontalPodAutoscalers } from './autoscaling.service'; +import { applicationIsKind, matchLabelsToLabelSelectorValue } from './utils'; const queryKeys = { applicationsForCluster: (environmentId: EnvironmentId) => [ @@ -29,6 +36,73 @@ const queryKeys = { namespace, name, ], + applicationRevisions: ( + environmentId: EnvironmentId, + namespace: string, + name: string, + labelSelector?: string + ) => [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'revisions', + labelSelector, + ], + applicationServices: ( + environmentId: EnvironmentId, + namespace: string, + name: string + ) => [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'services', + ], + ingressesForApplication: ( + environmentId: EnvironmentId, + namespace: string, + name: string + ) => [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'ingresses', + ], + applicationHorizontalPodAutoscalers: ( + environmentId: EnvironmentId, + namespace: string, + name: string + ) => [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'horizontalpodautoscalers', + ], + applicationPods: ( + environmentId: EnvironmentId, + namespace: string, + name: string + ) => [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'pods', + ], }; // useQuery to get a list of all applications from an array of namespaces @@ -62,6 +136,161 @@ export function useApplication( ); } +// test if I can get the previous revision +// useQuery to get an application's previous revision by environmentId, namespace, appKind and labelSelector +export function useApplicationRevisionList( + environmentId: EnvironmentId, + namespace: string, + name: string, + deploymentUid?: string, + labelSelector?: string, + appKind?: AppKind +) { + return useQuery( + queryKeys.applicationRevisions( + environmentId, + namespace, + name, + labelSelector + ), + () => + getApplicationRevisionList( + environmentId, + namespace, + deploymentUid, + appKind, + labelSelector + ), + { + ...withError('Unable to retrieve application revisions'), + enabled: !!labelSelector && !!appKind && !!deploymentUid, + } + ); +} + +// useApplicationServices returns a query for services that are related to the application (this doesn't include ingresses) +// Filtering the services by the application selector labels is done in the front end because: +// - The label selector query param in the kubernetes API filters by metadata.labels, but we need to filter the services by spec.selector +// - The field selector query param in the kubernetes API can filter the services by spec.selector, but it doesn't support chaining with 'OR', +// so we can't filter by services with at least one matching label. See: https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/#chained-selectors +export function useApplicationServices( + environmentId: EnvironmentId, + namespace: string, + appName: string, + app?: Application +) { + return useQuery( + queryKeys.applicationServices(environmentId, namespace, appName), + async () => { + if (!app) { + return []; + } + + // get the selector labels for the application + const appSelectorLabels = applicationIsKind('Pod', app) + ? app.metadata?.labels + : app.spec?.template?.metadata?.labels; + + // get all services in the namespace and filter them by the application selector labels + const services = await getNamespaceServices(environmentId, namespace); + const filteredServices = services.filter((service) => { + if (service.spec?.selector && appSelectorLabels) { + const serviceSelectorLabels = service.spec.selector; + // include the service if the service selector label matches at least one application selector label + return Object.keys(appSelectorLabels).some( + (key) => + serviceSelectorLabels[key] && + serviceSelectorLabels[key] === appSelectorLabels[key] + ); + } + return false; + }); + return filteredServices; + }, + { ...withError(`Unable to get services for ${appName}`), enabled: !!app } + ); +} + +// useApplicationHorizontalPodAutoscalers returns a query for horizontal pod autoscalers that are related to the application +export function useApplicationHorizontalPodAutoscalers( + environmentId: EnvironmentId, + namespace: string, + appName: string, + app?: Application +) { + return useQuery( + queryKeys.applicationHorizontalPodAutoscalers( + environmentId, + namespace, + appName + ), + async () => { + if (!app) { + return null; + } + + const horizontalPodAutoscalers = + await getNamespaceHorizontalPodAutoscalers(environmentId, namespace); + const filteredHorizontalPodAutoscalers = + horizontalPodAutoscalers.find((horizontalPodAutoscaler) => { + const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef; + if (scaleTargetRef) { + const scaleTargetRefName = scaleTargetRef.name; + const scaleTargetRefKind = scaleTargetRef.kind; + // include the horizontal pod autoscaler if the scale target ref name and kind match the application name and kind + return ( + scaleTargetRefName === app.metadata?.name && + scaleTargetRefKind === app.kind + ); + } + return false; + }) || null; + return filteredHorizontalPodAutoscalers; + }, + { + ...withError( + `Unable to get horizontal pod autoscalers${ + app ? ` for ${app.metadata?.name}` : '' + }` + ), + enabled: !!app, + } + ); +} + +// useApplicationPods returns a query for pods that are related to the application by the application selector labels +export function useApplicationPods( + environmentId: EnvironmentId, + namespace: string, + appName: string, + app?: Application +) { + return useQuery( + queryKeys.applicationPods(environmentId, namespace, appName), + async () => { + if (applicationIsKind('Pod', app)) { + return [app]; + } + const appSelector = app?.spec?.selector; + const labelSelector = matchLabelsToLabelSelectorValue( + appSelector?.matchLabels + ); + + // get all pods in the namespace using the application selector as the label selector query param + const pods = await getNamespacePods( + environmentId, + namespace, + labelSelector + ); + return pods; + }, + { + ...withError(`Unable to get pods for ${appName}`), + enabled: !!app, + } + ); +} + // useQuery to patch an application by environmentId, namespace, name and patch payload export function usePatchApplicationMutation( environmentId: EnvironmentId, @@ -69,22 +298,54 @@ export function usePatchApplicationMutation( name: string ) { return useMutation( - ({ - appKind, - path, - value, - }: { - appKind: AppKind; - path: string; - value: string; - }) => - patchApplication(environmentId, namespace, appKind, name, path, value), + ({ appKind, patch }: { appKind: AppKind; patch: ApplicationPatch }) => + patchApplication(environmentId, namespace, appKind, name, patch), + { + onSuccess: () => { + queryClient.invalidateQueries( + queryKeys.application(environmentId, namespace, name) + ); + }, + // patch application is used for patching and rollbacks, so handle the error where it's used instead of here + } + ); +} + +// useRedeployApplicationMutation gets all the pods for an application (using the matchLabels field in the labelSelector query param) and then deletes all of them, so that they are recreated +export function useRedeployApplicationMutation( + environmentId: number, + namespace: string, + name: string +) { + return useMutation( + async ({ labelSelector }: { labelSelector: string }) => { + try { + // get only the pods that match the labelSelector for the application + const pods = await getNamespacePods( + environmentId, + namespace, + labelSelector + ); + // delete all the pods to redeploy the application + await Promise.all( + pods.map((pod) => { + if (pod?.metadata?.name) { + return deletePod(environmentId, namespace, pod.metadata.name); + } + return Promise.resolve(); + }) + ); + } catch (error) { + throw new Error(`Unable to redeploy application: ${error}`); + } + }, { onSuccess: () => { queryClient.invalidateQueries( queryKeys.application(environmentId, namespace, name) ); }, + ...withError('Unable to redeploy application'), } ); } diff --git a/app/react/kubernetes/applications/application.service.ts b/app/react/kubernetes/applications/application.service.ts index 84e25dd15..9757a9166 100644 --- a/app/react/kubernetes/applications/application.service.ts +++ b/app/react/kubernetes/applications/application.service.ts @@ -5,15 +5,23 @@ import { Deployment, DaemonSet, StatefulSet, + ReplicaSetList, + ControllerRevisionList, } from 'kubernetes-types/apps/v1'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { isFulfilled } from '@/portainer/helpers/promise-utils'; -import { getPod, getPods, patchPod } from './pod.service'; -import { getNakedPods } from './utils'; -import { AppKind, Application, ApplicationList } from './types'; +import { getPod, getNamespacePods, patchPod } from './pod.service'; +import { filterRevisionsByOwnerUid, getNakedPods } from './utils'; +import { + AppKind, + Application, + ApplicationList, + ApplicationPatch, +} from './types'; +import { appRevisionAnnotation } from './constants'; // This file contains services for Kubernetes apps/v1 resources (Deployments, DaemonSets, StatefulSets) @@ -58,7 +66,7 @@ async function getApplicationsForNamespace( namespace, 'StatefulSet' ), - getPods(environmentId, namespace), + getNamespacePods(environmentId, namespace), ]); // find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset) const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets); @@ -147,8 +155,7 @@ export async function patchApplication( namespace: string, appKind: AppKind, name: string, - path: string, - value: string + patch: ApplicationPatch ) { try { switch (appKind) { @@ -158,8 +165,7 @@ export async function patchApplication( namespace, appKind, name, - path, - value + patch ); case 'DaemonSet': return await patchApplicationByKind( @@ -167,8 +173,8 @@ export async function patchApplication( namespace, appKind, name, - path, - value + patch, + 'application/strategic-merge-patch+json' ); case 'StatefulSet': return await patchApplicationByKind( @@ -176,11 +182,11 @@ export async function patchApplication( namespace, appKind, name, - path, - value + patch, + 'application/strategic-merge-patch+json' ); case 'Pod': - return await patchPod(environmentId, namespace, name, path, value); + return await patchPod(environmentId, namespace, name, patch); default: throw new Error(`Unknown application kind ${appKind}`); } @@ -197,23 +203,16 @@ async function patchApplicationByKind( namespace: string, appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet', name: string, - path: string, - value: string + patch: ApplicationPatch, + contentType = 'application/json-patch+json' ) { - const payload = [ - { - op: 'replace', - path, - value, - }, - ]; try { const res = await axios.patch( buildUrl(environmentId, namespace, `${appKind}s`, name), - payload, + patch, { headers: { - 'Content-Type': 'application/json-patch+json', + 'Content-Type': contentType, }, } ); @@ -254,10 +253,120 @@ async function getApplicationsByKind( } } +export async function getApplicationRevisionList( + environmentId: EnvironmentId, + namespace: string, + deploymentUid?: string, + appKind?: AppKind, + labelSelector?: string +) { + if (!deploymentUid) { + throw new Error('deploymentUid is required'); + } + try { + switch (appKind) { + case 'Deployment': { + const replicaSetList = await getReplicaSetList( + environmentId, + namespace, + labelSelector + ); + const replicaSets = replicaSetList.items; + // keep only replicaset(s) which are owned by the deployment with the given uid + const replicaSetsWithOwnerId = filterRevisionsByOwnerUid( + replicaSets, + deploymentUid + ); + // keep only replicaset(s) that have been a version of the Deployment + const replicaSetsWithRevisionAnnotations = + replicaSetsWithOwnerId.filter( + (rs) => !!rs.metadata?.annotations?.[appRevisionAnnotation] + ); + + return { + ...replicaSetList, + items: replicaSetsWithRevisionAnnotations, + } as ReplicaSetList; + } + case 'DaemonSet': + case 'StatefulSet': { + const controllerRevisionList = await getControllerRevisionList( + environmentId, + namespace, + labelSelector + ); + const controllerRevisions = controllerRevisionList.items; + // ensure the controller reference(s) is owned by the deployment with the given uid + const controllerRevisionsWithOwnerId = filterRevisionsByOwnerUid( + controllerRevisions, + deploymentUid + ); + + return { + ...controllerRevisionList, + items: controllerRevisionsWithOwnerId, + } as ControllerRevisionList; + } + default: + throw new Error(`Unknown application kind ${appKind}`); + } + } catch (e) { + throw parseAxiosError( + e as Error, + `Unable to retrieve revisions for ${appKind}` + ); + } +} + +export async function getReplicaSetList( + environmentId: EnvironmentId, + namespace: string, + labelSelector?: string +) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace, 'ReplicaSets'), + { + params: { + labelSelector, + }, + } + ); + return data; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve ReplicaSets'); + } +} + +export async function getControllerRevisionList( + environmentId: EnvironmentId, + namespace: string, + labelSelector?: string +) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace, 'ControllerRevisions'), + { + params: { + labelSelector, + }, + } + ); + return data; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve ControllerRevisions'); + } +} + function buildUrl( environmentId: EnvironmentId, namespace: string, - appKind: 'Deployments' | 'DaemonSets' | 'StatefulSets', + appKind: + | 'Deployments' + | 'DaemonSets' + | 'StatefulSets' + | 'ReplicaSets' + | 'ControllerRevisions', name?: string ) { let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`; diff --git a/app/react/kubernetes/applications/autoscaling.service.ts b/app/react/kubernetes/applications/autoscaling.service.ts new file mode 100644 index 000000000..3d03cb4ac --- /dev/null +++ b/app/react/kubernetes/applications/autoscaling.service.ts @@ -0,0 +1,14 @@ +import { HorizontalPodAutoscalerList } from 'kubernetes-types/autoscaling/v1'; + +import axios from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export async function getNamespaceHorizontalPodAutoscalers( + environmentId: EnvironmentId, + namespace: string +) { + const { data: autoScalarList } = await axios.get( + `/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers` + ); + return autoScalarList.items; +} diff --git a/app/react/kubernetes/applications/constants.ts b/app/react/kubernetes/applications/constants.ts index 1ce904392..61d0080a2 100644 --- a/app/react/kubernetes/applications/constants.ts +++ b/app/react/kubernetes/applications/constants.ts @@ -2,9 +2,25 @@ import { AppKind, DeploymentType } from './types'; // Portainer specific labels export const appStackNameLabel = 'io.portainer.kubernetes.application.stack'; +export const appStackIdLabel = 'io.portainer.kubernetes.application.stackid'; export const appOwnerLabel = 'io.portainer.kubernetes.application.owner'; export const appNoteAnnotation = 'io.portainer.kubernetes.application.note'; export const appDeployMethodLabel = 'io.portainer.kubernetes.application.kind'; +export const defaultDeploymentUniqueLabel = 'pod-template-hash'; + +export const appRevisionAnnotation = 'deployment.kubernetes.io/revision'; + +// unchangedAnnotationKeysForRollbackPatch lists the annotations that should be preserved from the deployment and not +// copied from the replicaset when rolling a deployment back +export const unchangedAnnotationKeysForRollbackPatch = [ + 'kubectl.kubernetes.io/last-applied-configuration', + appRevisionAnnotation, + 'deployment.kubernetes.io/revision-history', + 'deployment.kubernetes.io/desired-replicas', + 'deployment.kubernetes.io/max-replicas', + 'deprecated.deployment.rollback.to', + 'deprecated.daemonset.template.generation', +]; export const appKindToDeploymentTypeMap: Record< AppKind, diff --git a/app/react/kubernetes/applications/pod.service.ts b/app/react/kubernetes/applications/pod.service.ts index 34d83e607..f53c95d6d 100644 --- a/app/react/kubernetes/applications/pod.service.ts +++ b/app/react/kubernetes/applications/pod.service.ts @@ -1,12 +1,23 @@ import { Pod, PodList } from 'kubernetes-types/core/v1'; -import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { ApplicationPatch } from './types'; -export async function getPods(environmentId: EnvironmentId, namespace: string) { +export async function getNamespacePods( + environmentId: EnvironmentId, + namespace: string, + labelSelector?: string +) { try { const { data } = await axios.get( - buildUrl(environmentId, namespace) + buildUrl(environmentId, namespace), + { + params: { + labelSelector, + }, + } ); return data.items; } catch (e) { @@ -33,20 +44,12 @@ export async function patchPod( environmentId: EnvironmentId, namespace: string, name: string, - path: string, - value: string + patch: ApplicationPatch ) { - const payload = [ - { - op: 'replace', - path, - value, - }, - ]; try { return await axios.patch( buildUrl(environmentId, namespace, name), - payload, + patch, { headers: { 'Content-Type': 'application/json-patch+json', @@ -58,6 +61,18 @@ export async function patchPod( } } +export async function deletePod( + environmentId: EnvironmentId, + namespace: string, + name: string +) { + try { + return await axios.delete(buildUrl(environmentId, namespace, name)); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to delete pod'); + } +} + export function buildUrl( environmentId: EnvironmentId, namespace: string, diff --git a/app/react/kubernetes/applications/types.ts b/app/react/kubernetes/applications/types.ts index b637b3cc5..148df792d 100644 --- a/app/react/kubernetes/applications/types.ts +++ b/app/react/kubernetes/applications/types.ts @@ -5,11 +5,18 @@ import { DeploymentList, StatefulSet, StatefulSetList, + ReplicaSet, + ControllerRevision, } from 'kubernetes-types/apps/v1'; import { Pod, PodList } from 'kubernetes-types/core/v1'; +import { RawExtension } from 'kubernetes-types/runtime'; export type Application = Deployment | DaemonSet | StatefulSet | Pod; +// Revisions are have the previous application state and are used for rolling back applications to their previous state. +// Deployments use ReplicaSets, StatefulSets and DaemonSets use ControllerRevisions, and Pods don't have revisions. +export type Revision = ReplicaSet | ControllerRevision; + export type ApplicationList = | DeploymentList | DaemonSetList @@ -19,3 +26,11 @@ export type ApplicationList = export type AppKind = 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod'; export type DeploymentType = 'Replicated' | 'Global'; + +type Patch = { + op: 'replace' | 'add' | 'remove'; + path: string; + value: string | number | boolean | null | Record; +}[]; + +export type ApplicationPatch = Patch | RawExtension; diff --git a/app/react/kubernetes/applications/utils.ts b/app/react/kubernetes/applications/utils.ts index b793036ac..3461aeecc 100644 --- a/app/react/kubernetes/applications/utils.ts +++ b/app/react/kubernetes/applications/utils.ts @@ -1,18 +1,32 @@ -import { Deployment, DaemonSet, StatefulSet } from 'kubernetes-types/apps/v1'; +import { + Deployment, + DaemonSet, + StatefulSet, + ReplicaSet, + ReplicaSetList, + ControllerRevisionList, + ControllerRevision, +} from 'kubernetes-types/apps/v1'; import { Pod } from 'kubernetes-types/core/v1'; import filesizeParser from 'filesize-parser'; -import { Application } from './types'; -import { appOwnerLabel } from './constants'; +import { Application, ApplicationPatch, Revision } from './types'; +import { + appOwnerLabel, + defaultDeploymentUniqueLabel, + unchangedAnnotationKeysForRollbackPatch, + appRevisionAnnotation, +} from './constants'; +// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset +// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs +// getNakedPods returns an array of naked pods from an array of pods, deployments, daemonsets and statefulsets export function getNakedPods( pods: Pod[], deployments: Deployment[], daemonSets: DaemonSet[], statefulSets: StatefulSet[] ) { - // naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset - // https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs const appLabels = [ ...deployments.map((deployment) => deployment.spec?.selector.matchLabels), ...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels), @@ -37,7 +51,7 @@ export function getNakedPods( return nakedPods; } -// type guard to check if an application is a deployment, daemonset statefulset or pod +// type guard to check if an application is a deployment, daemonset, statefulset or pod export function applicationIsKind( appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod', application?: Application @@ -165,3 +179,151 @@ export function getResourceLimits(application: Application) { return limits; } + +// matchLabelsToLabelSelectorValue converts a map of labels to a label selector value that can be used in the +// labelSelector param for the kube api to filter kube resources by labels +export function matchLabelsToLabelSelectorValue(obj?: Record) { + if (!obj) return ''; + return Object.entries(obj) + .map(([key, value]) => `${key}=${value}`) + .join(','); +} + +// filterRevisionsByOwnerUid filters a list of revisions to only include revisions that have the given uid in their +// ownerReferences +export function filterRevisionsByOwnerUid( + revisions: T[], + uid: string +) { + return revisions.filter((revision) => { + const ownerReferencesUids = + revision.metadata?.ownerReferences?.map((or) => or.uid) || []; + return ownerReferencesUids.includes(uid); + }); +} + +// getRollbackPatchPayload returns the patch payload to rollback a deployment to the previous revision +// the patch should be able to update the deployment's template to the previous revision's template +export function getRollbackPatchPayload( + application: Deployment | StatefulSet | DaemonSet, + revisionList: ReplicaSetList | ControllerRevisionList +): ApplicationPatch { + switch (revisionList.kind) { + case 'ControllerRevisionList': { + const previousRevision = getPreviousControllerRevision( + revisionList.items + ); + if (!previousRevision.data) { + throw new Error('No data found in the previous revision.'); + } + return previousRevision.data; + } + case 'ReplicaSetList': { + const previousRevision = getPreviousReplicaSetRevision( + revisionList.items + ); + + // remove hash label before patching back into the deployment + const revisionTemplate = previousRevision.spec?.template; + if (revisionTemplate?.metadata?.labels) { + delete revisionTemplate.metadata.labels[defaultDeploymentUniqueLabel]; + } + + // build the patch payload for the deployment from the replica set + // keep the annotations to skip from the deployment, in the patch + const applicationAnnotations = application.metadata?.annotations || {}; + const applicationAnnotationsInPatch = + unchangedAnnotationKeysForRollbackPatch.reduce((acc, annotationKey) => { + if (applicationAnnotations[annotationKey]) { + acc[annotationKey] = applicationAnnotations[annotationKey]; + } + return acc; + }, {} as Record); + + // add any annotations from the target revision that shouldn't be skipped + const revisionAnnotations = previousRevision.metadata?.annotations || {}; + const revisionAnnotationsInPatch = Object.entries( + revisionAnnotations + ).reduce((acc, [annotationKey, annotationValue]) => { + if (!unchangedAnnotationKeysForRollbackPatch.includes(annotationKey)) { + acc[annotationKey] = annotationValue; + } + return acc; + }, {} as Record); + + const patchAnnotations = { + ...applicationAnnotationsInPatch, + ...revisionAnnotationsInPatch, + }; + + // Create a patch of the Deployment that replaces spec.template + const deploymentRollbackPatch = [ + { + op: 'replace', + path: '/spec/template', + value: revisionTemplate, + }, + { + op: 'replace', + path: '/metadata/annotations', + value: patchAnnotations, + }, + ].filter((p) => !!p.value); // remove any patch that has no value + + return deploymentRollbackPatch; + } + default: + throw new Error(`Unknown revision list kind ${revisionList.kind}.`); + } +} + +function getPreviousReplicaSetRevision(replicaSets: ReplicaSet[]) { + // sort replicaset(s) using the revision annotation number (old to new). + // Kubectl uses the same revision annotation key to determine the previous version + // (see the Revision function, and where it's used https://github.com/kubernetes/kubectl/blob/27ec3dafa658d8873b3d9287421d636048b51921/pkg/util/deployment/deployment.go#LL70C11-L70C11) + const sortedReplicaSets = replicaSets.sort((a, b) => { + const aRevision = + Number(a.metadata?.annotations?.[appRevisionAnnotation]) || 0; + const bRevision = + Number(b.metadata?.annotations?.[appRevisionAnnotation]) || 0; + return aRevision - bRevision; + }); + + // if there are less than 2 revisions, there is no previous revision to rollback to + if (sortedReplicaSets.length < 2) { + throw new Error( + 'There are no previous revisions to rollback to. Please check the application revisions.' + ); + } + + // get the second to last revision + const previousRevision = sortedReplicaSets[sortedReplicaSets.length - 2]; + return previousRevision; +} + +function getPreviousControllerRevision( + controllerRevisions: ControllerRevision[] +) { + // sort the list of ControllerRevisions by revision, using the creationTimestamp as a tie breaker (old to new) + const sortedControllerRevisions = controllerRevisions.sort((a, b) => { + if (a.revision === b.revision) { + return ( + new Date(a.metadata?.creationTimestamp || '').getTime() - + new Date(b.metadata?.creationTimestamp || '').getTime() + ); + } + return a.revision - b.revision; + }); + + // if there are less than 2 revisions, there is no previous revision to rollback to + if (sortedControllerRevisions.length < 2) { + throw new Error( + 'There are no previous revisions to rollback to. Please check the application revisions.' + ); + } + + // get the second to last revision + const previousRevision = + sortedControllerRevisions[sortedControllerRevisions.length - 2]; + return previousRevision; +} diff --git a/app/react/portainer/gitops/RefField/RefField.tsx b/app/react/portainer/gitops/RefField/RefField.tsx index ba0dcecaa..5e581c26e 100644 --- a/app/react/portainer/gitops/RefField/RefField.tsx +++ b/app/react/portainer/gitops/RefField/RefField.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren, ReactNode } from 'react'; import { SchemaOf, string } from 'yup'; -import { StackId } from '@/react/docker/stacks/types'; +import { StackId } from '@/react/common/stacks/types'; import { useStateWrapper } from '@/react/hooks/useStateWrapper'; import { FormControl } from '@@/form-components/FormControl'; diff --git a/app/react/portainer/gitops/RefField/RefSelector.tsx b/app/react/portainer/gitops/RefField/RefSelector.tsx index 39012e429..84fed1711 100644 --- a/app/react/portainer/gitops/RefField/RefSelector.tsx +++ b/app/react/portainer/gitops/RefField/RefSelector.tsx @@ -1,4 +1,4 @@ -import { StackId } from '@/react/docker/stacks/types'; +import { StackId } from '@/react/common/stacks/types'; import { useGitRefs } from '@/react/portainer/gitops/queries/useGitRefs'; import { Select } from '@@/form-components/Input';