From 959c527be7b0c6f3d8cd1c4eece373f6dfc7bcb0 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Fri, 25 Oct 2024 12:28:05 +1300 Subject: [PATCH] refactor(apps): migrate applications view to react [r8s-124] (#28) --- app/kubernetes/__module.js | 4 +- .../react/components/applications.ts | 21 - app/kubernetes/react/components/index.ts | 12 - app/kubernetes/react/views/index.ts | 5 + .../views/applications/applications.html | 59 --- .../views/applications/applications.js | 9 - .../applications/applicationsController.js | 181 ------- .../AppYAMLEditor/useApplicationYAML.ts | 12 +- .../ApplicationContainersDatatable.tsx | 3 +- .../ApplicationAutoScalingTable.tsx | 2 +- .../ApplicationDetailsWidget.tsx | 6 +- .../ApplicationPersistentDataTable.tsx | 2 +- .../RedeployApplicationButton.tsx | 2 +- .../RollbackApplicationButton.tsx | 6 +- .../ApplicationEventsDatatable.tsx | 8 +- .../DetailsView/ApplicationSummaryWidget.tsx | 6 +- .../usePlacementTableData.tsx | 3 +- .../useApplicationEventsTableData.tsx | 8 +- .../ApplicationsDatatable.tsx | 86 ++-- .../ListView/ApplicationsDatatable/index.ts | 1 + .../ApplicationsStacksDatatable.tsx | 83 ++-- .../NamespaceFilter.tsx | 11 +- .../TableActions.tsx | 24 - .../ApplicationsStacksDatatable/types.ts | 7 - .../ListView/ApplicationsView.tsx | 54 +++ .../ListView/useKubeAppsTableStore.ts | 55 +++ .../applications/application.queries.ts | 451 ------------------ .../applications/application.service.ts | 312 ------------ .../kubernetes/applications/pod.service.ts | 72 --- .../applications/queries/getPods.ts | 37 ++ .../applications/queries/query-keys.ts | 105 ++++ .../applications/queries/useApplication.ts | 168 +++++++ .../useApplicationHorizontalPodAutoscaler.ts | 76 +++ .../queries/useApplicationPods.ts | 44 ++ .../queries/useApplicationRevisionList.ts | 173 +++++++ .../queries/useApplicationServices.ts | 57 +++ .../applications/queries/useApplications.ts | 54 +++ .../queries/useDeleteApplicationsMutation.ts | 191 ++++++++ .../useHorizontalPodAutoScaler.ts} | 34 +- .../queries/usePatchApplicationMutation.ts | 155 ++++++ .../queries/useRedeployApplicationMutation.ts | 70 +++ .../NodeApplicationsDatatable.tsx | 2 +- 42 files changed, 1378 insertions(+), 1293 deletions(-) delete mode 100644 app/kubernetes/react/components/applications.ts delete mode 100644 app/kubernetes/views/applications/applications.html delete mode 100644 app/kubernetes/views/applications/applications.js delete mode 100644 app/kubernetes/views/applications/applicationsController.js create mode 100644 app/react/kubernetes/applications/ListView/ApplicationsDatatable/index.ts delete mode 100644 app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/TableActions.tsx create mode 100644 app/react/kubernetes/applications/ListView/ApplicationsView.tsx create mode 100644 app/react/kubernetes/applications/ListView/useKubeAppsTableStore.ts delete mode 100644 app/react/kubernetes/applications/application.queries.ts delete mode 100644 app/react/kubernetes/applications/application.service.ts delete mode 100644 app/react/kubernetes/applications/pod.service.ts create mode 100644 app/react/kubernetes/applications/queries/getPods.ts create mode 100644 app/react/kubernetes/applications/queries/query-keys.ts create mode 100644 app/react/kubernetes/applications/queries/useApplication.ts create mode 100644 app/react/kubernetes/applications/queries/useApplicationHorizontalPodAutoscaler.ts create mode 100644 app/react/kubernetes/applications/queries/useApplicationPods.ts create mode 100644 app/react/kubernetes/applications/queries/useApplicationRevisionList.ts create mode 100644 app/react/kubernetes/applications/queries/useApplicationServices.ts create mode 100644 app/react/kubernetes/applications/queries/useApplications.ts create mode 100644 app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts rename app/react/kubernetes/applications/{autoscaling.service.ts => queries/useHorizontalPodAutoScaler.ts} (64%) create mode 100644 app/react/kubernetes/applications/queries/usePatchApplicationMutation.ts create mode 100644 app/react/kubernetes/applications/queries/useRedeployApplicationMutation.ts diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 1eaee62ad..2528e67bc 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -207,7 +207,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo const applications = { name: 'kubernetes.applications', - url: '/applications', + url: '/applications?tab', views: { 'content@': { component: 'kubernetesApplicationsView', @@ -233,7 +233,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo const application = { name: 'kubernetes.applications.application', - url: '/:namespace/:name?resource-type&tab', + url: '/:namespace/:name?resource-type', views: { 'content@': { component: 'applicationDetailsView', diff --git a/app/kubernetes/react/components/applications.ts b/app/kubernetes/react/components/applications.ts deleted file mode 100644 index 844ab6a92..000000000 --- a/app/kubernetes/react/components/applications.ts +++ /dev/null @@ -1,21 +0,0 @@ -import angular from 'angular'; - -import { r2a } from '@/react-tools/react2angular'; -import { withUIRouter } from '@/react-tools/withUIRouter'; -import { withCurrentUser } from '@/react-tools/withCurrentUser'; -import { ApplicationsDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable'; - -export const applicationsModule = angular - .module('portainer.kubernetes.react.components.applications', []) - - .component( - 'kubernetesApplicationsDatatable', - r2a(withUIRouter(withCurrentUser(ApplicationsDatatable)), [ - 'namespace', - 'namespaces', - 'onNamespaceChange', - 'onRefresh', - 'onRemove', - 'hideStacks', - ]) - ).name; diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 081b5fbce..e42874d86 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -23,7 +23,6 @@ import { ApplicationSummarySection } from '@/react/kubernetes/applications/compo import { withFormValidation } from '@/react-tools/withFormValidation'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector'; -import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable'; import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable'; import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName'; import { StackNameLabelInsight } from '@/react/kubernetes/DeployView/StackName/StackNameLabelInsight'; @@ -61,14 +60,12 @@ import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema'; import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable'; -import { applicationsModule } from './applications'; import { namespacesModule } from './namespaces'; import { clusterManagementModule } from './clusterManagement'; import { registriesModule } from './registries'; export const ngModule = angular .module('portainer.kubernetes.react.components', [ - applicationsModule, namespacesModule, clusterManagementModule, registriesModule, @@ -208,15 +205,6 @@ export const ngModule = angular ['formValues', 'oldFormValues'] ) ) - .component( - 'kubernetesApplicationsStacksDatatable', - r2a(withUIRouter(withCurrentUser(ApplicationsStacksDatatable)), [ - 'onRemove', - 'namespace', - 'namespaces', - 'onNamespaceChange', - ]) - ) .component( 'kubernetesIntegratedApplicationsDatatable', r2a(withUIRouter(withCurrentUser(IntegratedAppsDatatable)), [ diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 2deae5fb5..8ee8538e3 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -11,6 +11,7 @@ import { ServicesView } from '@/react/kubernetes/services/ServicesView'; import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView'; import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView'; import { CreateNamespaceView } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceView'; +import { ApplicationsView } from '@/react/kubernetes/applications/ListView/ApplicationsView'; import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView'; import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView'; import { NamespacesView } from '@/react/kubernetes/namespaces/ListView/NamespacesView'; @@ -55,6 +56,10 @@ export const viewsModule = angular [] ) ) + .component( + 'kubernetesApplicationsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ApplicationsView))), []) + ) .component( 'applicationDetailsView', r2a( diff --git a/app/kubernetes/views/applications/applications.html b/app/kubernetes/views/applications/applications.html deleted file mode 100644 index 44fc20be8..000000000 --- a/app/kubernetes/views/applications/applications.html +++ /dev/null @@ -1,59 +0,0 @@ - - - - -
-
-
- - - - - Applications - - - - - - Stacks - - - - - - - - - -
-
-
diff --git a/app/kubernetes/views/applications/applications.js b/app/kubernetes/views/applications/applications.js deleted file mode 100644 index da0a7ff07..000000000 --- a/app/kubernetes/views/applications/applications.js +++ /dev/null @@ -1,9 +0,0 @@ -angular.module('portainer.kubernetes').component('kubernetesApplicationsView', { - templateUrl: './applications.html', - controller: 'KubernetesApplicationsController', - controllerAs: 'ctrl', - bindings: { - $transition$: '<', - endpoint: '<', - }, -}); diff --git a/app/kubernetes/views/applications/applicationsController.js b/app/kubernetes/views/applications/applicationsController.js deleted file mode 100644 index e7fb8d431..000000000 --- a/app/kubernetes/views/applications/applicationsController.js +++ /dev/null @@ -1,181 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash-es'; -import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants'; -import { KubernetesPortainerApplicationStackNameLabel } from 'Kubernetes/models/application/models'; -import { getDeploymentOptions } from '@/react/portainer/environments/environment.service'; -import { getStacksFromApplications } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/getStacksFromApplications'; -import { getApplications } from '@/react/kubernetes/applications/application.queries.ts'; -import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; -class KubernetesApplicationsController { - /* @ngInject */ - constructor( - $async, - $state, - $scope, - Authentication, - Notifications, - KubernetesApplicationService, - EndpointService, - HelmService, - KubernetesConfigurationService, - LocalStorage, - StackService, - KubernetesNamespaceService - ) { - this.$async = $async; - this.$state = $state; - this.$scope = $scope; - this.Authentication = Authentication; - this.Notifications = Notifications; - this.KubernetesApplicationService = KubernetesApplicationService; - this.HelmService = HelmService; - this.KubernetesConfigurationService = KubernetesConfigurationService; - this.Authentication = Authentication; - this.LocalStorage = LocalStorage; - this.StackService = StackService; - this.KubernetesNamespaceService = KubernetesNamespaceService; - - this.onInit = this.onInit.bind(this); - this.removeAction = this.removeAction.bind(this); - this.removeActionAsync = this.removeActionAsync.bind(this); - this.removeStacksAction = this.removeStacksAction.bind(this); - this.removeStacksActionAsync = this.removeStacksActionAsync.bind(this); - this.onPublishingModeClick = this.onPublishingModeClick.bind(this); - this.onChangeNamespaceDropdown = this.onChangeNamespaceDropdown.bind(this); - } - - selectTab(index) { - this.LocalStorage.storeActiveTab('applications', index); - } - - async removeStacksActionAsync(selectedItems) { - let actionCount = selectedItems.length; - for (const stack of selectedItems) { - try { - const isAppFormCreated = stack.Applications.some((x) => !x.ApplicationKind); - - if (isAppFormCreated) { - const promises = _.map(stack.Applications, (app) => this.KubernetesApplicationService.delete(app)); - await Promise.all(promises); - } - - await this.StackService.removeKubernetesStacksByName(stack.Name, stack.ResourcePool, false, this.endpoint.Id); - - this.Notifications.success('Stack successfully removed', stack.Name); - _.remove(this.state.stacks, { Name: stack.Name }); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to remove stack'); - } finally { - --actionCount; - if (actionCount === 0) { - this.$state.reload(this.$state.current); - } - } - } - } - - removeStacksAction(selectedItems) { - return this.$async(this.removeStacksActionAsync, selectedItems); - } - - async removeActionAsync(selectedItems) { - let actionCount = selectedItems.length; - for (const application of selectedItems) { - try { - if (application.ApplicationType === KubernetesApplicationTypes.Helm) { - await this.HelmService.uninstall(this.endpoint.Id, application); - } else { - if (application.Metadata.labels && application.Metadata.labels[KubernetesPortainerApplicationStackNameLabel]) { - // remove stack if no app left in the stack - const appsInNamespace = await getApplications(this.endpoint.Id, { namespace: application.ResourcePool, withDependencies: false }); - const stacksInNamespace = getStacksFromApplications(appsInNamespace); - const stack = stacksInNamespace.find((x) => x.Name === application.StackName); - if (stack.Applications.length === 0 && application.StackId) { - await this.StackService.remove({ Id: application.StackId }, false, this.endpoint.Id); - } - } - await this.KubernetesApplicationService.delete(application); - } - this.Notifications.success('Application successfully removed', application.Name); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to remove application'); - } finally { - --actionCount; - if (actionCount === 0) { - this.$state.reload(this.$state.current); - } - } - } - } - - removeAction(selectedItems) { - this.$async(() => this.removeActionAsync(selectedItems)); - } - - onPublishingModeClick(application) { - this.state.activeTab = 1; - _.forEach(this.state.ports, (item) => { - item.Expanded = false; - item.Highlighted = false; - if (item.Name === application.Name && item.Ports.length > 1) { - item.Expanded = true; - item.Highlighted = true; - } - }); - } - - onChangeNamespaceDropdown(namespaceName) { - return this.$async(async () => { - this.state.namespaceName = namespaceName; - // save the selected namespaceName in local storage with the key 'kubernetes_namespace_filter_${environmentId}_${userID}' - this.LocalStorage.storeNamespaceFilter(this.endpoint.Id, this.user.ID, namespaceName); - }); - } - - async onInit() { - this.state = { - activeTab: this.LocalStorage.getActiveTab('applications'), - currentName: this.$state.$current.name, - isAdmin: this.Authentication.isAdmin(), - viewReady: false, - applications: [], - stacks: [], - ports: [], - namespaces: [], - namespaceName: '', - }; - - this.deploymentOptions = await getDeploymentOptions(); - - this.user = this.Authentication.getUserDetails(); - this.state.namespaces = await getNamespaces(this.endpoint.Id); - - const savedNamespace = this.LocalStorage.getNamespaceFilter(this.endpoint.Id, this.user.ID); // could be null if not found, and '' if all namepsaces is selected - const preferredNamespace = savedNamespace === null ? 'default' : savedNamespace; - - this.state.namespaces = this.state.namespaces.filter((n) => n.Status.phase === 'Active'); - this.state.namespaces = _.sortBy(this.state.namespaces, 'Name'); - // set all namespaces ('') if there are no namespaces, or if all namespaces is selected - if (!this.state.namespaces.length || preferredNamespace === '') { - this.state.namespaceName = ''; - } else { - // otherwise, set the preferred namespaceName if it exists, otherwise set the first namespaceName - this.state.namespaceName = this.state.namespaces.find((n) => n.Name === preferredNamespace) ? preferredNamespace : this.state.namespaces[0].Name; - } - - this.state.viewReady = true; - } - - $onInit() { - return this.$async(this.onInit); - } - - $onDestroy() { - if (this.state.currentName !== this.$state.$current.name) { - this.LocalStorage.storeActiveTab('applications', 0); - } - } -} - -export default KubernetesApplicationsController; -angular.module('portainer.kubernetes').controller('KubernetesApplicationsController', KubernetesApplicationsController); diff --git a/app/react/kubernetes/applications/DetailsView/AppYAMLEditor/useApplicationYAML.ts b/app/react/kubernetes/applications/DetailsView/AppYAMLEditor/useApplicationYAML.ts index 8016e5ff6..f8cadbb2c 100644 --- a/app/react/kubernetes/applications/DetailsView/AppYAMLEditor/useApplicationYAML.ts +++ b/app/react/kubernetes/applications/DetailsView/AppYAMLEditor/useApplicationYAML.ts @@ -3,12 +3,10 @@ import { useMemo } from 'react'; import { useServicesQuery } from '@/react/kubernetes/services/service'; -import { - useApplication, - useApplicationHorizontalPodAutoscaler, - useApplicationServices, -} from '../../application.queries'; -import { useHorizontalAutoScalarQuery } from '../../autoscaling.service'; +import { useHorizontalPodAutoScaler } from '../../queries/useHorizontalPodAutoScaler'; +import { useApplication } from '../../queries/useApplication'; +import { useApplicationServices } from '../../queries/useApplicationServices'; +import { useApplicationHorizontalPodAutoscaler } from '../../queries/useApplicationHorizontalPodAutoscaler'; export function useApplicationYAML() { const { @@ -56,7 +54,7 @@ export function useApplicationYAML() { application ); const { data: autoScalarYAML, ...autoScalarYAMLQuery } = - useHorizontalAutoScalarQuery( + useHorizontalPodAutoScaler( environmentId, namespace, autoScalar?.metadata?.name || '', diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable/ApplicationContainersDatatable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable/ApplicationContainersDatatable.tsx index a0d05dbad..b365c5851 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable/ApplicationContainersDatatable.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable/ApplicationContainersDatatable.tsx @@ -11,7 +11,8 @@ import { useEnvironment } from '@/react/portainer/environments/queries'; import { Datatable } from '@@/datatables'; import { useTableState } from '@@/datatables/useTableState'; -import { useApplication, useApplicationPods } from '../../application.queries'; +import { useApplication } from '../../queries/useApplication'; +import { useApplicationPods } from '../../queries/useApplicationPods'; import { ContainerRowData } from './types'; import { getColumns } from './columns'; diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationAutoScalingTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationAutoScalingTable.tsx index 798140bd1..b51237dcf 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationAutoScalingTable.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationAutoScalingTable.tsx @@ -7,7 +7,7 @@ import { TextTip } from '@@/Tip/TextTip'; import { Tooltip } from '@@/Tip/Tooltip'; import { Application } from '../../types'; -import { useApplicationHorizontalPodAutoscaler } from '../../application.queries'; +import { useApplicationHorizontalPodAutoscaler } from '../../queries/useApplicationHorizontalPodAutoscaler'; type Props = { environmentId: EnvironmentId; diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx index b845ee8da..f905fd843 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationDetailsWidget.tsx @@ -11,12 +11,10 @@ import { AddButton, Button } from '@@/buttons'; import { Link } from '@@/Link'; import { Icon } from '@@/Icon'; -import { - useApplication, - useApplicationServices, -} from '../../application.queries'; import { applicationIsKind, isExternalApplication } from '../../utils'; import { appStackIdLabel } from '../../constants'; +import { useApplication } from '../../queries/useApplication'; +import { useApplicationServices } from '../../queries/useApplicationServices'; import { RestartApplicationButton } from './RestartApplicationButton'; import { RedeployApplicationButton } from './RedeployApplicationButton'; diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationPersistentDataTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationPersistentDataTable.tsx index b77e40171..af63febeb 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationPersistentDataTable.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationPersistentDataTable.tsx @@ -12,7 +12,7 @@ import { Link } from '@@/Link'; import { Application } from '../../types'; import { applicationIsKind } from '../../utils'; -import { useApplicationPods } from '../../application.queries'; +import { useApplicationPods } from '../../queries/useApplicationPods'; type Props = { environmentId: EnvironmentId; diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RedeployApplicationButton.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RedeployApplicationButton.tsx index 93d413e70..1ea80a4dc 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RedeployApplicationButton.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RedeployApplicationButton.tsx @@ -12,12 +12,12 @@ 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'; +import { useRedeployApplicationMutation } from '../../queries/useRedeployApplicationMutation'; type Props = { environmentId: EnvironmentId; diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RollbackApplicationButton.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RollbackApplicationButton.tsx index 078935390..e03ad6714 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RollbackApplicationButton.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/RollbackApplicationButton.tsx @@ -14,10 +14,6 @@ import { buildConfirmButton } from '@@/modals/utils'; import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { Tooltip } from '@@/Tip/Tooltip'; -import { - useApplicationRevisionList, - usePatchApplicationMutation, -} from '../../application.queries'; import { applicationIsKind, getRollbackPatchPayload, @@ -25,6 +21,8 @@ import { } from '../../utils'; import { Application } from '../../types'; import { appDeployMethodLabel } from '../../constants'; +import { useApplicationRevisionList } from '../../queries/useApplicationRevisionList'; +import { usePatchApplicationMutation } from '../../queries/usePatchApplicationMutation'; type Props = { environmentId: EnvironmentId; diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx index f2046d55c..f578dc355 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx @@ -6,14 +6,12 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { useTableState } from '@@/datatables/useTableState'; -import { - useApplication, - useApplicationPods, - useApplicationServices, -} from '../application.queries'; import { EventsDatatable } from '../../components/EventsDatatable'; import { useEvents } from '../../queries/useEvents'; import { AppKind } from '../types'; +import { useApplication } from '../queries/useApplication'; +import { useApplicationServices } from '../queries/useApplicationServices'; +import { useApplicationPods } from '../queries/useApplicationPods'; const storageKey = 'k8sAppEventsDatatable'; const settingsStore = createStore(storageKey, { id: 'Date', desc: true }); diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx index e9f964cc9..bd78f69c1 100644 --- a/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx +++ b/app/react/kubernetes/applications/DetailsView/ApplicationSummaryWidget.tsx @@ -34,12 +34,10 @@ import { getTotalPods, isExternalApplication, } from '../utils'; -import { - useApplication, - usePatchApplicationMutation, -} from '../application.queries'; import { Application, ApplicationPatch } from '../types'; import { useNamespaceQuery } from '../../namespaces/queries/useNamespaceQuery'; +import { useApplication } from '../queries/useApplication'; +import { usePatchApplicationMutation } from '../queries/usePatchApplicationMutation'; export function ApplicationSummaryWidget() { const stateAndParams = useCurrentStateAndParams(); diff --git a/app/react/kubernetes/applications/DetailsView/PlacementsDatatable/usePlacementTableData.tsx b/app/react/kubernetes/applications/DetailsView/PlacementsDatatable/usePlacementTableData.tsx index c6c4fcc61..b68abbff9 100644 --- a/app/react/kubernetes/applications/DetailsView/PlacementsDatatable/usePlacementTableData.tsx +++ b/app/react/kubernetes/applications/DetailsView/PlacementsDatatable/usePlacementTableData.tsx @@ -14,8 +14,9 @@ import { } from '@@/datatables/types'; import { useTableState } from '@@/datatables/useTableState'; -import { useApplication, useApplicationPods } from '../../application.queries'; import { Affinity, Label, NodePlacementRowData } from '../types'; +import { useApplication } from '../../queries/useApplication'; +import { useApplicationPods } from '../../queries/useApplicationPods'; interface TableSettings extends BasicTableSettings, RefreshableTableSettings {} diff --git a/app/react/kubernetes/applications/DetailsView/useApplicationEventsTableData.tsx b/app/react/kubernetes/applications/DetailsView/useApplicationEventsTableData.tsx index 9ab709b5d..099fcade4 100644 --- a/app/react/kubernetes/applications/DetailsView/useApplicationEventsTableData.tsx +++ b/app/react/kubernetes/applications/DetailsView/useApplicationEventsTableData.tsx @@ -5,12 +5,10 @@ import { createStore } from '@/react/kubernetes/datatables/default-kube-datatabl import { useTableState } from '@@/datatables/useTableState'; -import { - useApplication, - useApplicationPods, - useApplicationServices, -} from '../application.queries'; import { useEvents } from '../../queries/useEvents'; +import { useApplication } from '../queries/useApplication'; +import { useApplicationServices } from '../queries/useApplicationServices'; +import { useApplicationPods } from '../queries/useApplicationPods'; const storageKey = 'k8sAppEventsDatatable'; const settingsStore = createStore(storageKey, { id: 'Date', desc: true }); diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx index 4286f0e34..799d21fcc 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx @@ -1,28 +1,29 @@ import { useMemo } from 'react'; import { BoxIcon } from 'lucide-react'; import { groupBy, partition } from 'lodash'; +import { useRouter } from '@uirouter/react'; -import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; import { useAuthorizations } from '@/react/hooks/useUser'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { KubernetesApplicationTypes } from '@/kubernetes/models/application/models/appConstants'; +import { useEnvironment } from '@/react/portainer/environments/queries'; import { TableSettingsMenu } from '@@/datatables'; -import { useRepeater } from '@@/datatables/useRepeater'; import { DeleteButton } from '@@/buttons/DeleteButton'; import { AddButton } from '@@/buttons'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; import { NamespaceFilter } from '../ApplicationsStacksDatatable/NamespaceFilter'; -import { Namespace } from '../ApplicationsStacksDatatable/types'; -import { useApplications } from '../../application.queries'; import { PodKubernetesInstanceLabel, PodManagedByLabel } from '../../constants'; +import { useApplications } from '../../queries/useApplications'; +import { ApplicationsTableSettings } from '../useKubeAppsTableStore'; +import { useDeleteApplicationsMutation } from '../../queries/useDeleteApplicationsMutation'; +import { getStacksFromApplications } from '../ApplicationsStacksDatatable/getStacksFromApplications'; import { Application, ApplicationRowData, ConfigKind } from './types'; import { useColumns } from './useColumns'; @@ -31,35 +32,30 @@ import { SubRow } from './SubRow'; import { HelmInsightsBox } from './HelmInsightsBox'; export function ApplicationsDatatable({ - onRefresh, - onRemove, - namespace = '', - namespaces, - onNamespaceChange, - hideStacks, + tableState, + hideStacks = false, }: { - onRefresh: () => void; - onRemove: (selectedItems: Application[]) => void; - namespace?: string; - namespaces: Array; - onNamespaceChange(namespace: string): void; - hideStacks: boolean; + hideStacks?: boolean; + tableState: ApplicationsTableSettings & { + setSearch: (value: string) => void; + search: string; + }; }) { - const envId = useEnvironmentId(); - const envQuery = useCurrentEnvironment(); - const namespaceListQuery = useNamespacesQuery(envId); - - const tableState = useKubeStore('kubernetes.applications', 'Name'); - useRepeater(tableState.autoRefreshRate, onRefresh); - - const hasWriteAuthQuery = useAuthorizations( + const router = useRouter(); + const environmentId = useEnvironmentId(); + const restrictSecretsQuery = useEnvironment( + environmentId, + (env) => env.Kubernetes.Configuration.RestrictSecrets + ); + const namespaceListQuery = useNamespacesQuery(environmentId); + const { authorized: hasWriteAuth } = useAuthorizations( 'K8sApplicationsW', undefined, false ); - const applicationsQuery = useApplications(envId, { + const applicationsQuery = useApplications(environmentId, { refetchInterval: tableState.autoRefreshRate * 1000, - namespace, + namespace: tableState.namespace, withDependencies: true, }); const applications = useApplicationsRowData(applicationsQuery.data); @@ -69,20 +65,25 @@ export function ApplicationsDatatable({ (application) => !isSystemNamespace(application.ResourcePool, namespaceListQuery.data) ); + const stacks = getStacksFromApplications(filteredApplications); + const removeApplicationsMutation = useDeleteApplicationsMutation({ + environmentId, + stacks, + reportStacks: false, + }); const columns = useColumns(hideStacks); return ( !isSystemNamespace(row.original.ResourcePool, namespaceListQuery.data) } @@ -91,25 +92,22 @@ export function ApplicationsDatatable({ )} renderTableActions={(selectedItems) => - hasWriteAuthQuery.authorized && ( + hasWriteAuth && ( <> onRemove(selectedItems)} + onConfirmed={() => handleRemoveApplications(selectedItems)} /> - Add with form - ) @@ -123,18 +121,16 @@ export function ApplicationsDatatable({
-
-
@@ -143,6 +139,14 @@ export function ApplicationsDatatable({ } /> ); + + function handleRemoveApplications(applications: ApplicationRowData[]) { + removeApplicationsMutation.mutate(applications, { + onSuccess: () => { + router.stateService.reload(); + }, + }); + } } function useApplicationsRowData( diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/index.ts b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/index.ts new file mode 100644 index 000000000..0864b2a59 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/index.ts @@ -0,0 +1 @@ +export { ApplicationsDatatable } from './ApplicationsDatatable'; diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx index 123e2c54d..023c35aab 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx @@ -1,52 +1,44 @@ import { List } from 'lucide-react'; +import { useRouter } from '@uirouter/react'; -import { useAuthorizations } from '@/react/hooks/useUser'; +import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; -import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; -import { useTableState } from '@@/datatables/useTableState'; import { TableSettingsMenu } from '@@/datatables'; +import { DeleteButton } from '@@/buttons/DeleteButton'; -import { useApplications } from '../../application.queries'; +import { useApplications } from '../../queries/useApplications'; +import { ApplicationsTableSettings } from '../useKubeAppsTableStore'; +import { useDeleteApplicationsMutation } from '../../queries/useDeleteApplicationsMutation'; import { columns } from './columns'; import { SubRows } from './SubRows'; -import { Namespace, Stack } from './types'; import { NamespaceFilter } from './NamespaceFilter'; -import { TableActions } from './TableActions'; import { getStacksFromApplications } from './getStacksFromApplications'; - -const storageKey = 'kubernetes.applications.stacks'; - -const settingsStore = createStore(storageKey); - -interface Props { - onRemove(selectedItems: Array): void; - namespace?: string; - namespaces: Array; - onNamespaceChange(namespace: string): void; -} +import { Stack } from './types'; export function ApplicationsStacksDatatable({ - onRemove, - namespace = '', - namespaces, - onNamespaceChange, -}: Props) { - const tableState = useTableState(settingsStore, storageKey); - - const envId = useEnvironmentId(); - const applicationsQuery = useApplications(envId, { + tableState, +}: { + tableState: ApplicationsTableSettings & { + setSearch: (value: string) => void; + search: string; + }; +}) { + const router = useRouter(); + const environmentId = useEnvironmentId(); + const namespaceListQuery = useNamespacesQuery(environmentId); + const { authorized: hasWriteAuth } = useAuthorizations('K8sApplicationsW'); + const applicationsQuery = useApplications(environmentId, { refetchInterval: tableState.autoRefreshRate * 1000, - namespace, + namespace: tableState.namespace, withDependencies: true, }); - const namespaceListQuery = useNamespacesQuery(envId); const applications = applicationsQuery.data ?? []; const filteredApplications = tableState.showSystemResources ? applications @@ -54,10 +46,12 @@ export function ApplicationsStacksDatatable({ (item) => !isSystemNamespace(item.ResourcePool, namespaceListQuery.data ?? []) ); - - const { authorized } = useAuthorizations('K8sApplicationsW'); - const stacks = getStacksFromApplications(filteredApplications); + const removeApplicationsMutation = useDeleteApplicationsMutation({ + environmentId, + stacks, + reportStacks: true, + }); return ( ( )} - noWidget description={
@@ -92,7 +85,14 @@ export function ApplicationsStacksDatatable({
} renderTableActions={(selectedItems) => ( - + + handleRemoveStacks(selectedItems)} + data-cy="k8sApp-removeStackButton" + /> + )} renderTableSettings={() => ( @@ -103,4 +103,13 @@ export function ApplicationsStacksDatatable({ data-cy="applications-stacks-datatable" /> ); + + function handleRemoveStacks(selectedItems: Stack[]) { + const applications = selectedItems.flatMap((stack) => stack.Applications); + removeApplicationsMutation.mutate(applications, { + onSuccess: () => { + router.stateService.reload(); + }, + }); + } } diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/NamespaceFilter.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/NamespaceFilter.tsx index c6b2b6f7d..5195d6d07 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/NamespaceFilter.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/NamespaceFilter.tsx @@ -1,13 +1,16 @@ import { Filter } from 'lucide-react'; import { useEffect } from 'react'; +import { PortainerNamespace } from '@/react/kubernetes/namespaces/types'; + import { Icon } from '@@/Icon'; import { Select } from '@@/form-components/Input'; import { InputGroup } from '@@/form-components/InputGroup'; -import { Namespace } from './types'; - -function transformNamespaces(namespaces: Namespace[], showSystem?: boolean) { +function transformNamespaces( + namespaces: PortainerNamespace[], + showSystem?: boolean +) { const transformedNamespaces = namespaces.map(({ Name, IsSystem }) => ({ label: IsSystem ? `${Name} - system` : Name, value: Name, @@ -26,7 +29,7 @@ export function NamespaceFilter({ onChange, showSystem, }: { - namespaces: Namespace[]; + namespaces: PortainerNamespace[]; value: string; onChange: (value: string) => void; showSystem?: boolean; diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/TableActions.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/TableActions.tsx deleted file mode 100644 index 732960fb2..000000000 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/TableActions.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Authorized } from '@/react/hooks/useUser'; - -import { DeleteButton } from '@@/buttons/DeleteButton'; - -import { Stack } from './types'; - -export function TableActions({ - selectedItems, - onRemove, -}: { - selectedItems: Array; - onRemove: (selectedItems: Array) => void; -}) { - return ( - - onRemove(selectedItems)} - data-cy="k8sApp-removeStackButton" - /> - - ); -} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts index 915c535fc..5f6f4736b 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts @@ -12,13 +12,6 @@ export interface TableSettings RefreshableTableSettings, SystemResourcesTableSettings {} -export interface Namespace { - Id: string; - Name: string; - Yaml: string; - IsSystem?: boolean; -} - export type Stack = { Name: string; ResourcePool: string; diff --git a/app/react/kubernetes/applications/ListView/ApplicationsView.tsx b/app/react/kubernetes/applications/ListView/ApplicationsView.tsx new file mode 100644 index 000000000..b69e67edb --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsView.tsx @@ -0,0 +1,54 @@ +import { BoxIcon, List } from 'lucide-react'; +import { useCurrentStateAndParams } from '@uirouter/react'; + +import { usePublicSettings } from '@/react/portainer/settings/queries/usePublicSettings'; + +import { PageHeader } from '@@/PageHeader'; +import { Tab, WidgetTabs, findSelectedTabIndex } from '@@/Widget/WidgetTabs'; + +import { ApplicationsDatatable } from './ApplicationsDatatable'; +import { ApplicationsStacksDatatable } from './ApplicationsStacksDatatable'; +import { useKubeAppsTableStore } from './useKubeAppsTableStore'; + +export function ApplicationsView() { + const tableState = useKubeAppsTableStore('kubernetes.applications', 'Name'); + const hideStacksQuery = usePublicSettings({ + select: (settings) => + settings.GlobalDeploymentOptions.hideStacksFunctionality, + }); + const hideStacks = hideStacksQuery.isLoading || !!hideStacksQuery.data; + + const tabs: Tab[] = [ + { + name: 'Applications', + icon: BoxIcon, + widget: , + selectedTabParam: 'applications', + }, + { + name: 'Stacks', + icon: List, + widget: , + selectedTabParam: 'stacks', + }, + ]; + + const currentTabIndex = findSelectedTabIndex( + useCurrentStateAndParams(), + tabs + ); + + return ( + <> + + {hideStacks ? ( + + ) : ( + <> + +
{tabs[currentTabIndex].widget}
+ + )} + + ); +} diff --git a/app/react/kubernetes/applications/ListView/useKubeAppsTableStore.ts b/app/react/kubernetes/applications/ListView/useKubeAppsTableStore.ts new file mode 100644 index 000000000..92c0eb7d6 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/useKubeAppsTableStore.ts @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; + +import { + refreshableSettings, + createPersistedStore, + ZustandSetFunc, +} from '@@/datatables/types'; +import { useTableState } from '@@/datatables/useTableState'; + +import { systemResourcesSettings } from '../../datatables/SystemResourcesSettings'; + +interface NamespaceSelectSettings { + namespace: string; + setNamespace: (namespace: string) => void; +} +export type ApplicationsTableSettings = NamespaceSelectSettings & TableSettings; + +export function namespaceSelectSettings( + set: ZustandSetFunc, + namespace = 'default' +): NamespaceSelectSettings { + return { + namespace, + setNamespace: (namespace: string) => set((s) => ({ ...s, namespace })), + }; +} + +export function createStore( + storageKey: string, + initialSortBy?: string | { id: string; desc: boolean }, + create: ( + set: ZustandSetFunc + ) => Omit = () => ({}) as T +) { + return createPersistedStore( + storageKey, + initialSortBy, + (set) => + ({ + ...refreshableSettings(set), + ...systemResourcesSettings(set), + ...namespaceSelectSettings(set), + ...create(set), + }) as T + ); +} + +export function useKubeAppsTableStore( + ...args: Parameters> +) { + const [store] = useState(() => createStore(...args)); + return useTableState(store, args[0]); +} diff --git a/app/react/kubernetes/applications/application.queries.ts b/app/react/kubernetes/applications/application.queries.ts deleted file mode 100644 index 8c38a6928..000000000 --- a/app/react/kubernetes/applications/application.queries.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { UseQueryResult, useMutation, useQuery } from '@tanstack/react-query'; -import { Pod, PodList } from 'kubernetes-types/core/v1'; - -import { - queryClient, - withError, - withGlobalError, -} from '@/react-tools/react-query'; -import { EnvironmentId } from '@/react/portainer/environments/types'; -import axios, { parseAxiosError } from '@/portainer/services/axios'; - -import { getNamespaceServices } from '../services/service'; -import { parseKubernetesAxiosError } from '../axiosError'; - -import { - getApplication, - patchApplication, - getApplicationRevisionList, -} from './application.service'; -import type { AppKind, Application, ApplicationPatch } from './types'; -import { Application as K8sApplication } from './ListView/ApplicationsDatatable/types'; -import { deletePod } from './pod.service'; -import { getNamespaceHorizontalPodAutoscalers } from './autoscaling.service'; -import { applicationIsKind, matchLabelsToLabelSelectorValue } from './utils'; - -const queryKeys = { - applications: (environmentId: EnvironmentId, params?: GetAppsParams) => - [ - 'environments', - environmentId, - 'kubernetes', - 'applications', - params, - ] as const, - application: ( - environmentId: EnvironmentId, - namespace: string, - name: string, - yaml?: boolean - ) => - [ - 'environments', - environmentId, - 'kubernetes', - 'applications', - namespace, - name, - yaml, - ] as const, - applicationRevisions: ( - environmentId: EnvironmentId, - namespace: string, - name: string, - labelSelector?: string - ) => - [ - 'environments', - environmentId, - 'kubernetes', - 'applications', - namespace, - name, - 'revisions', - labelSelector, - ] as const, - applicationServices: ( - environmentId: EnvironmentId, - namespace: string, - name: string - ) => - [ - 'environments', - environmentId, - 'kubernetes', - 'applications', - namespace, - name, - 'services', - ] as const, - ingressesForApplication: ( - environmentId: EnvironmentId, - namespace: string, - name: string - ) => - [ - 'environments', - environmentId, - 'kubernetes', - 'applications', - namespace, - name, - 'ingresses', - ] as const, - applicationHorizontalPodAutoscalers: ( - environmentId: EnvironmentId, - namespace: string, - name: string - ) => - [ - 'environments', - environmentId, - 'kubernetes', - 'applications', - namespace, - name, - 'horizontalpodautoscalers', - ] as const, - applicationPods: ( - environmentId: EnvironmentId, - namespace: string, - name: string - ) => - [ - 'environments', - environmentId, - 'kubernetes', - 'applications', - namespace, - name, - 'pods', - ] as const, -}; - -// when yaml is set to true, the expected return type is a string -export function useApplication( - environmentId: EnvironmentId, - namespace: string, - name: string, - appKind?: AppKind, - options?: { autoRefreshRate?: number; yaml?: boolean } -): UseQueryResult { - return useQuery( - queryKeys.application(environmentId, namespace, name, options?.yaml), - () => - getApplication(environmentId, namespace, name, appKind, options?.yaml), - { - ...withError('Unable to retrieve application'), - refetchInterval() { - return options?.autoRefreshRate ?? false; - }, - } - ); -} - -// 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 useApplicationHorizontalPodAutoscaler( - 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 matchingHorizontalPodAutoscaler = - 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 matchingHorizontalPodAutoscaler; - }, - { - ...withError( - `Unable to get horizontal pod autoscaler${ - 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, - options?: { autoRefreshRate?: number } -) { - 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, - refetchInterval() { - return options?.autoRefreshRate ?? false; - }, - } - ); -} - -async function getNamespacePods( - environmentId: EnvironmentId, - namespace: string, - labelSelector?: string -) { - try { - const { data } = await axios.get( - `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`, - { - params: { - labelSelector, - }, - } - ); - const items = (data.items || []).map( - (pod) => - { - ...pod, - kind: 'Pod', - apiVersion: data.apiVersion, - } - ); - return items; - } catch (e) { - throw parseKubernetesAxiosError( - e, - `Unable to retrieve Pods in namespace '${namespace}'` - ); - } -} - -// useQuery to patch an application by environmentId, namespace, name and patch payload -export function usePatchApplicationMutation( - environmentId: EnvironmentId, - namespace: string, - name: string -) { - return useMutation( - ({ - appKind, - patch, - contentType = 'application/json-patch+json', - }: { - appKind: AppKind; - patch: ApplicationPatch; - contentType?: - | 'application/json-patch+json' - | 'application/strategic-merge-patch+json'; - }) => - patchApplication( - environmentId, - namespace, - appKind, - name, - patch, - contentType - ), - { - 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'), - } - ); -} - -type GetAppsParams = { - namespace?: string; - nodeName?: string; - withDependencies?: boolean; -}; - -type GetAppsQueryOptions = { - refetchInterval?: number; -} & GetAppsParams; - -// useQuery to get a list of all applications from an array of namespaces -export function useApplications( - environmentId: EnvironmentId, - queryOptions?: GetAppsQueryOptions -) { - const { refetchInterval, ...params } = queryOptions ?? {}; - return useQuery( - queryKeys.applications(environmentId, params), - () => getApplications(environmentId, params), - { - refetchInterval, - ...withGlobalError('Unable to retrieve applications'), - } - ); -} - -// get all applications from a namespace -export async function getApplications( - environmentId: EnvironmentId, - params?: GetAppsParams -) { - try { - const { data } = await axios.get( - `/kubernetes/${environmentId}/applications`, - { params } - ); - return data; - } catch (e) { - throw parseAxiosError(e, 'Unable to retrieve applications'); - } -} diff --git a/app/react/kubernetes/applications/application.service.ts b/app/react/kubernetes/applications/application.service.ts deleted file mode 100644 index ea6fed15b..000000000 --- a/app/react/kubernetes/applications/application.service.ts +++ /dev/null @@ -1,312 +0,0 @@ -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 { parseKubernetesAxiosError } from '../axiosError'; - -import { getPod, patchPod } from './pod.service'; -import { filterRevisionsByOwnerUid } from './utils'; -import { AppKind, Application, ApplicationPatch } from './types'; -import { appRevisionAnnotation } from './constants'; - -// if not known, get the type of an application (Deployment, DaemonSet, StatefulSet or naked pod) by name -export async function getApplication< - T extends Application | string = Application, ->( - environmentId: EnvironmentId, - namespace: string, - name: string, - appKind?: AppKind, - yaml?: boolean -) { - // if resourceType is known, get the application by type and name - if (appKind) { - switch (appKind) { - case 'Deployment': - case 'DaemonSet': - case 'StatefulSet': - return getApplicationByKind( - environmentId, - namespace, - appKind, - name, - yaml - ); - case 'Pod': - return getPod(environmentId, namespace, name, yaml); - default: - throw new Error('Unknown resource type'); - } - } - - // if resourceType is not known, get the application by name and return the first one that is fulfilled - const [deployment, daemonSet, statefulSet, pod] = await Promise.allSettled([ - getApplicationByKind( - environmentId, - namespace, - 'Deployment', - name, - yaml - ), - getApplicationByKind( - environmentId, - namespace, - 'DaemonSet', - name, - yaml - ), - getApplicationByKind( - environmentId, - namespace, - 'StatefulSet', - name, - yaml - ), - getPod(environmentId, namespace, name, yaml), - ]); - - if (isFulfilled(deployment)) { - return deployment.value; - } - if (isFulfilled(daemonSet)) { - return daemonSet.value; - } - if (isFulfilled(statefulSet)) { - return statefulSet.value; - } - if (isFulfilled(pod)) { - return pod.value; - } - throw new Error('Unable to retrieve application'); -} - -export async function patchApplication( - environmentId: EnvironmentId, - namespace: string, - appKind: AppKind, - name: string, - patch: ApplicationPatch, - contentType: string = 'application/json-patch+json' -) { - switch (appKind) { - case 'Deployment': - return patchApplicationByKind( - environmentId, - namespace, - appKind, - name, - patch, - contentType - ); - case 'DaemonSet': - return patchApplicationByKind( - environmentId, - namespace, - appKind, - name, - patch, - contentType - ); - case 'StatefulSet': - return patchApplicationByKind( - environmentId, - namespace, - appKind, - name, - patch, - contentType - ); - case 'Pod': - return patchPod(environmentId, namespace, name, patch); - default: - throw new Error(`Unknown application kind ${appKind}`); - } -} - -async function patchApplicationByKind( - environmentId: EnvironmentId, - namespace: string, - appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet', - name: string, - patch: ApplicationPatch, - contentType = 'application/json-patch+json' -) { - try { - const res = await axios.patch( - buildUrl(environmentId, namespace, `${appKind}s`, name), - patch, - { - headers: { - 'Content-Type': contentType, - }, - } - ); - return res; - } catch (e) { - throw parseKubernetesAxiosError(e, 'Unable to patch application'); - } -} - -async function getApplicationByKind< - T extends Application | string = Application, ->( - environmentId: EnvironmentId, - namespace: string, - appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet', - name: string, - yaml?: boolean -) { - try { - const { data } = await axios.get( - buildUrl(environmentId, namespace, `${appKind}s`, name), - { - headers: { Accept: yaml ? 'application/yaml' : 'application/json' }, - // this logic is to get the latest YAML response - // axios-cache-adapter looks for the response headers to determine if the response should be cached - // to avoid writing requestInterceptor, adding a query param to the request url - params: yaml - ? { - _: Date.now(), - } - : null, - } - ); - return data; - } catch (e) { - throw parseKubernetesAxiosError(e, 'Unable to retrieve application'); - } -} - -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 parseKubernetesAxiosError(e, '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 parseKubernetesAxiosError( - e, - 'Unable to retrieve ControllerRevisions' - ); - } -} - -function buildUrl( - environmentId: EnvironmentId, - namespace: string, - appKind: - | 'Deployments' - | 'DaemonSets' - | 'StatefulSets' - | 'ReplicaSets' - | 'ControllerRevisions', - name?: string -) { - let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`; - if (name) { - baseUrl += `/${name}`; - } - return baseUrl; -} diff --git a/app/react/kubernetes/applications/pod.service.ts b/app/react/kubernetes/applications/pod.service.ts deleted file mode 100644 index f5f3ed683..000000000 --- a/app/react/kubernetes/applications/pod.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Pod } from 'kubernetes-types/core/v1'; - -import { EnvironmentId } from '@/react/portainer/environments/types'; -import axios, { parseAxiosError } from '@/portainer/services/axios'; - -import { parseKubernetesAxiosError } from '../axiosError'; - -import { ApplicationPatch } from './types'; - -export async function getPod( - environmentId: EnvironmentId, - namespace: string, - name: string, - yaml?: boolean -) { - try { - const { data } = await axios.get( - buildUrl(environmentId, namespace, name), - { - headers: { Accept: yaml ? 'application/yaml' : 'application/json' }, - } - ); - return data; - } catch (e) { - throw parseKubernetesAxiosError(e, 'Unable to retrieve pod'); - } -} - -export async function patchPod( - environmentId: EnvironmentId, - namespace: string, - name: string, - patch: ApplicationPatch -) { - try { - return await axios.patch( - buildUrl(environmentId, namespace, name), - patch, - { - headers: { - 'Content-Type': 'application/json-patch+json', - }, - } - ); - } catch (e) { - throw parseAxiosError(e, 'Unable to update pod'); - } -} - -export async function deletePod( - environmentId: EnvironmentId, - namespace: string, - name: string -) { - try { - return await axios.delete(buildUrl(environmentId, namespace, name)); - } catch (e) { - throw parseKubernetesAxiosError(e as Error, 'Unable to delete pod'); - } -} - -export function buildUrl( - environmentId: EnvironmentId, - namespace: string, - name?: string -) { - let baseUrl = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`; - if (name) { - baseUrl += `/${name}`; - } - return baseUrl; -} diff --git a/app/react/kubernetes/applications/queries/getPods.ts b/app/react/kubernetes/applications/queries/getPods.ts new file mode 100644 index 000000000..494b4df2d --- /dev/null +++ b/app/react/kubernetes/applications/queries/getPods.ts @@ -0,0 +1,37 @@ +import { Pod, PodList } from 'kubernetes-types/core/v1'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios from '@/portainer/services/axios'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +export async function getPods( + environmentId: EnvironmentId, + namespace: string, + labelSelector?: string +) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`, + { + params: { + labelSelector, + }, + } + ); + const items = (data.items || []).map( + (pod) => + { + ...pod, + kind: 'Pod', + apiVersion: data.apiVersion, + } + ); + return items; + } catch (e) { + throw parseKubernetesAxiosError( + e, + `Unable to retrieve Pods in namespace '${namespace}'` + ); + } +} diff --git a/app/react/kubernetes/applications/queries/query-keys.ts b/app/react/kubernetes/applications/queries/query-keys.ts new file mode 100644 index 000000000..87c042dc4 --- /dev/null +++ b/app/react/kubernetes/applications/queries/query-keys.ts @@ -0,0 +1,105 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export type GetAppsParams = { + namespace?: string; + nodeName?: string; + withDependencies?: boolean; +}; + +export const queryKeys = { + applications: (environmentId: EnvironmentId, params?: GetAppsParams) => + [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + params, + ] as const, + application: ( + environmentId: EnvironmentId, + namespace: string, + name: string, + yaml?: boolean + ) => + [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + yaml, + ] as const, + applicationRevisions: ( + environmentId: EnvironmentId, + namespace: string, + name: string, + labelSelector?: string + ) => + [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'revisions', + labelSelector, + ] as const, + applicationServices: ( + environmentId: EnvironmentId, + namespace: string, + name: string + ) => + [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'services', + ] as const, + ingressesForApplication: ( + environmentId: EnvironmentId, + namespace: string, + name: string + ) => + [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'ingresses', + ] as const, + applicationHorizontalPodAutoscalers: ( + environmentId: EnvironmentId, + namespace: string, + name: string + ) => + [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'horizontalpodautoscalers', + ] as const, + applicationPods: ( + environmentId: EnvironmentId, + namespace: string, + name: string + ) => + [ + 'environments', + environmentId, + 'kubernetes', + 'applications', + namespace, + name, + 'pods', + ] as const, +}; diff --git a/app/react/kubernetes/applications/queries/useApplication.ts b/app/react/kubernetes/applications/queries/useApplication.ts new file mode 100644 index 000000000..7632ac76c --- /dev/null +++ b/app/react/kubernetes/applications/queries/useApplication.ts @@ -0,0 +1,168 @@ +import { UseQueryResult, useQuery } from '@tanstack/react-query'; +import { DaemonSet, Deployment, StatefulSet } from 'kubernetes-types/apps/v1'; +import { Pod } from 'kubernetes-types/core/v1'; + +import { withGlobalError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { isFulfilled } from '@/portainer/helpers/promise-utils'; +import axios from '@/portainer/services/axios'; + +import { AppKind, Application } from '../types'; +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { queryKeys } from './query-keys'; + +/** + * @returns an application from the Kubernetes API proxy (deployment, statefulset, daemonSet or pod) + * when yaml is set to true, the expected return type is a string + */ +export function useApplication( + environmentId: EnvironmentId, + namespace: string, + name: string, + appKind?: AppKind, + options?: { autoRefreshRate?: number; yaml?: boolean } +): UseQueryResult { + return useQuery( + queryKeys.application(environmentId, namespace, name, options?.yaml), + () => + getApplication(environmentId, namespace, name, appKind, options?.yaml), + { + ...withGlobalError('Unable to retrieve application'), + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +// if not known, get the type of an application (Deployment, DaemonSet, StatefulSet or naked pod) by name +async function getApplication( + environmentId: EnvironmentId, + namespace: string, + name: string, + appKind?: AppKind, + yaml?: boolean +) { + // if resourceType is known, get the application by type and name + if (appKind) { + switch (appKind) { + case 'Deployment': + case 'DaemonSet': + case 'StatefulSet': + return getApplicationByKind( + environmentId, + namespace, + appKind, + name, + yaml + ); + case 'Pod': + return getPod(environmentId, namespace, name, yaml); + default: + throw new Error('Unknown resource type'); + } + } + + // if resourceType is not known, get the application by name and return the first one that is fulfilled + const [deployment, daemonSet, statefulSet, pod] = await Promise.allSettled([ + getApplicationByKind( + environmentId, + namespace, + 'Deployment', + name, + yaml + ), + getApplicationByKind( + environmentId, + namespace, + 'DaemonSet', + name, + yaml + ), + getApplicationByKind( + environmentId, + namespace, + 'StatefulSet', + name, + yaml + ), + getPod(environmentId, namespace, name, yaml), + ]); + + if (isFulfilled(deployment)) { + return deployment.value; + } + if (isFulfilled(daemonSet)) { + return daemonSet.value; + } + if (isFulfilled(statefulSet)) { + return statefulSet.value; + } + if (isFulfilled(pod)) { + return pod.value; + } + throw new Error('Unable to retrieve application'); +} + +async function getPod( + environmentId: EnvironmentId, + namespace: string, + name: string, + yaml?: boolean +) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods/${name}`, + { + headers: { Accept: yaml ? 'application/yaml' : 'application/json' }, + } + ); + return data; + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to retrieve pod'); + } +} + +async function getApplicationByKind< + T extends Application | string = Application, +>( + environmentId: EnvironmentId, + namespace: string, + appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet', + name: string, + yaml?: boolean +) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace, `${appKind}s`, name), + { + headers: { Accept: yaml ? 'application/yaml' : 'application/json' }, + // this logic is to get the latest YAML response + // axios-cache-adapter looks for the response headers to determine if the response should be cached + // to avoid writing requestInterceptor, adding a query param to the request url + params: yaml + ? { + _: Date.now(), + } + : null, + } + ); + return data; + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to retrieve application'); + } +} + +function buildUrl( + environmentId: EnvironmentId, + namespace: string, + appKind: 'Deployments' | 'DaemonSets' | 'StatefulSets', + name?: string +) { + let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`; + if (name) { + baseUrl += `/${name}`; + } + return baseUrl; +} diff --git a/app/react/kubernetes/applications/queries/useApplicationHorizontalPodAutoscaler.ts b/app/react/kubernetes/applications/queries/useApplicationHorizontalPodAutoscaler.ts new file mode 100644 index 000000000..ac3ad9799 --- /dev/null +++ b/app/react/kubernetes/applications/queries/useApplicationHorizontalPodAutoscaler.ts @@ -0,0 +1,76 @@ +import { useQuery } from '@tanstack/react-query'; +import { HorizontalPodAutoscalerList } from 'kubernetes-types/autoscaling/v1'; + +import { withGlobalError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios from '@/portainer/services/axios'; + +import type { Application } from '../types'; +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { queryKeys } from './query-keys'; + +// useApplicationHorizontalPodAutoscalers returns a query for horizontal pod autoscalers that are related to the application +export function useApplicationHorizontalPodAutoscaler( + 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 matchingHorizontalPodAutoscaler = + 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 matchingHorizontalPodAutoscaler; + }, + { + ...withGlobalError( + `Unable to get horizontal pod autoscaler${ + app ? ` for ${app.metadata?.name}` : '' + }` + ), + enabled: !!app, + } + ); +} + +async function getNamespaceHorizontalPodAutoscalers( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data: autoScalarList } = + await axios.get( + `/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers` + ); + return autoScalarList.items; + } catch (e) { + throw parseKubernetesAxiosError( + e, + 'Unable to retrieve horizontal pod autoscalers' + ); + } +} diff --git a/app/react/kubernetes/applications/queries/useApplicationPods.ts b/app/react/kubernetes/applications/queries/useApplicationPods.ts new file mode 100644 index 000000000..03042ee73 --- /dev/null +++ b/app/react/kubernetes/applications/queries/useApplicationPods.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import { Pod } from 'kubernetes-types/core/v1'; + +import { withGlobalError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { applicationIsKind, matchLabelsToLabelSelectorValue } from '../utils'; +import { Application } from '../types'; + +import { queryKeys } from './query-keys'; +import { getPods } from './getPods'; + +// 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, + options?: { autoRefreshRate?: number } +) { + 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 getPods(environmentId, namespace, labelSelector); + return pods; + }, + { + ...withGlobalError(`Unable to get pods for ${appName}`), + enabled: !!app, + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} diff --git a/app/react/kubernetes/applications/queries/useApplicationRevisionList.ts b/app/react/kubernetes/applications/queries/useApplicationRevisionList.ts new file mode 100644 index 000000000..afef43301 --- /dev/null +++ b/app/react/kubernetes/applications/queries/useApplicationRevisionList.ts @@ -0,0 +1,173 @@ +import { useQuery } from '@tanstack/react-query'; +import { + ReplicaSetList, + ControllerRevisionList, +} from 'kubernetes-types/apps/v1'; + +import { withGlobalError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { AppKind } from '../types'; +import { appRevisionAnnotation } from '../constants'; +import { filterRevisionsByOwnerUid } from '../utils'; +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { queryKeys } from './query-keys'; + +// 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 + ), + { + ...withGlobalError('Unable to retrieve application revisions'), + enabled: !!labelSelector && !!appKind && !!deploymentUid, + } + ); +} + +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}` + ); + } +} + +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 parseKubernetesAxiosError(e, 'Unable to retrieve ReplicaSets'); + } +} + +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 parseKubernetesAxiosError( + e, + 'Unable to retrieve ControllerRevisions' + ); + } +} + +function buildUrl( + environmentId: EnvironmentId, + namespace: string, + appKind: + | 'Deployments' + | 'DaemonSets' + | 'StatefulSets' + | 'ReplicaSets' + | 'ControllerRevisions', + name?: string +) { + let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`; + if (name) { + baseUrl += `/${name}`; + } + return baseUrl; +} diff --git a/app/react/kubernetes/applications/queries/useApplicationServices.ts b/app/react/kubernetes/applications/queries/useApplicationServices.ts new file mode 100644 index 000000000..5cfc0bc17 --- /dev/null +++ b/app/react/kubernetes/applications/queries/useApplicationServices.ts @@ -0,0 +1,57 @@ +import { useQuery } from '@tanstack/react-query'; +import { Pod } from 'kubernetes-types/core/v1'; + +import { withGlobalError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { getNamespaceServices } from '../../services/service'; +import type { Application } from '../types'; +import { applicationIsKind } from '../utils'; + +import { queryKeys } from './query-keys'; + +// 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; + }, + { + ...withGlobalError(`Unable to get services for ${appName}`), + enabled: !!app, + } + ); +} diff --git a/app/react/kubernetes/applications/queries/useApplications.ts b/app/react/kubernetes/applications/queries/useApplications.ts new file mode 100644 index 000000000..9d09ffad0 --- /dev/null +++ b/app/react/kubernetes/applications/queries/useApplications.ts @@ -0,0 +1,54 @@ +import { useQuery } from '@tanstack/react-query'; + +import { withGlobalError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { Application } from '../ListView/ApplicationsDatatable/types'; + +import { queryKeys } from './query-keys'; + +type GetAppsParams = { + namespace?: string; + nodeName?: string; + withDependencies?: boolean; +}; + +type GetAppsQueryOptions = { + refetchInterval?: number; +} & GetAppsParams; + +/** + * @returns a UseQueryResult that can be used to fetch a list of applications from an array of namespaces. + * This api call goes to the portainer api, not the kubernetes proxy api. + */ +export function useApplications( + environmentId: EnvironmentId, + queryOptions?: GetAppsQueryOptions +) { + const { refetchInterval, ...params } = queryOptions ?? {}; + return useQuery( + queryKeys.applications(environmentId, params), + () => getApplications(environmentId, params), + { + refetchInterval, + ...withGlobalError('Unable to retrieve applications'), + } + ); +} + +// get all applications from a namespace +export async function getApplications( + environmentId: EnvironmentId, + params?: GetAppsParams +) { + try { + const { data } = await axios.get( + `/kubernetes/${environmentId}/applications`, + { params } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve applications'); + } +} diff --git a/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts b/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts new file mode 100644 index 000000000..732f6dbb5 --- /dev/null +++ b/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts @@ -0,0 +1,191 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { getAllSettledItems } from '@/portainer/helpers/promise-utils'; +import { withGlobalError } from '@/react-tools/react-query'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { pluralize } from '@/portainer/helpers/strings'; + +import { parseKubernetesAxiosError } from '../../axiosError'; +import { ApplicationRowData } from '../ListView/ApplicationsDatatable/types'; +import { Stack } from '../ListView/ApplicationsStacksDatatable/types'; + +import { queryKeys } from './query-keys'; + +export function useDeleteApplicationsMutation({ + environmentId, + stacks, + reportStacks, +}: { + environmentId: EnvironmentId; + stacks: Stack[]; + reportStacks?: boolean; +}) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (applications: ApplicationRowData[]) => + deleteApplications(applications, stacks, environmentId), + onSuccess: ({ settledAppDeletions, settledStackDeletions }) => { + // one error notification per rejected item + settledAppDeletions.rejectedItems.forEach(({ item, reason }) => { + notifyError( + `Failed to remove application '${item.Name}'`, + new Error(reason) + ); + }); + settledStackDeletions.rejectedItems.forEach(({ item, reason }) => { + notifyError(`Failed to remove stack '${item.Name}'`, new Error(reason)); + }); + + // one success notification for all fulfilled items + if (settledAppDeletions.fulfilledItems.length && !reportStacks) { + notifySuccess( + `${pluralize( + settledAppDeletions.fulfilledItems.length, + 'Application' + )} successfully removed`, + settledAppDeletions.fulfilledItems.map((item) => item.Name).join(', ') + ); + } + if (settledStackDeletions.fulfilledItems.length && reportStacks) { + notifySuccess( + `${pluralize( + settledStackDeletions.fulfilledItems.length, + 'Stack' + )} successfully removed`, + settledStackDeletions.fulfilledItems + .map((item) => item.Name) + .join(', ') + ); + } + queryClient.invalidateQueries(queryKeys.applications(environmentId)); + }, + ...withGlobalError('Unable to remove applications'), + }); +} + +async function deleteApplications( + applications: ApplicationRowData[], + stacks: Stack[], + environmentId: EnvironmentId +) { + const settledAppDeletions = await getAllSettledItems( + applications, + (application) => deleteApplication(application, stacks, environmentId) + ); + + // Delete stacks that have no applications left (stacks object has been mutated by deleteApplication) + const stacksToDelete = stacks.filter( + (stack) => stack.Applications.length === 0 + ); + const settledStackDeletions = await getAllSettledItems( + stacksToDelete, + (stack) => deleteStack(stack, environmentId) + ); + + return { settledAppDeletions, settledStackDeletions }; +} + +async function deleteStack(stack: Stack, environmentId: EnvironmentId) { + try { + await axios.delete(`/stacks/name/${stack.Name}`, { + params: { + external: false, + name: stack.Name, + endpointId: environmentId, + namespace: stack.ResourcePool, + }, + }); + } catch (error) { + throw parseAxiosError(error, 'Unable to remove stack'); + } +} + +async function deleteApplication( + application: ApplicationRowData, + stacks: Stack[], + environmentId: EnvironmentId +) { + switch (application.ApplicationType) { + case 'Deployment': + case 'DaemonSet': + case 'StatefulSet': + await deleteKubernetesApplication(application, stacks, environmentId); + break; + case 'Pod': + await deletePodApplication(application, stacks, environmentId); + break; + case 'Helm': + await uninstallHelmApplication(application, environmentId); + break; + default: + throw new Error( + `Unknown application type: ${application.ApplicationType}` + ); + } +} + +async function deleteKubernetesApplication( + application: ApplicationRowData, + stacks: Stack[], + environmentId: EnvironmentId +) { + try { + await axios.delete( + `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${ + application.ResourcePool + }/${application.ApplicationType.toLowerCase()}s/${application.Name}` + ); + removeApplicationFromStack(application, stacks); + } catch (error) { + throw parseKubernetesAxiosError(error, 'Unable to remove application'); + } +} + +async function deletePodApplication( + application: ApplicationRowData, + stacks: Stack[], + environmentId: EnvironmentId +) { + try { + await axios.delete( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${application.ResourcePool}/pods/${application.Name}` + ); + removeApplicationFromStack(application, stacks); + } catch (error) { + throw parseKubernetesAxiosError(error, 'Unable to remove application'); + } +} + +async function uninstallHelmApplication( + application: ApplicationRowData, + environmentId: EnvironmentId +) { + try { + await axios.delete( + `/endpoints/${environmentId}/kubernetes/helm/${application.Name}`, + { params: { namespace: application.ResourcePool } } + ); + } catch (error) { + // parseAxiosError, because it's a regular portainer api error + throw parseAxiosError(error, 'Unable to remove application'); + } +} + +// mutate the stacks array to remove the application +function removeApplicationFromStack( + application: ApplicationRowData, + stacks: Stack[] +) { + const stack = stacks.find( + (stack) => + stack.Name === application.StackName && + stack.ResourcePool === application.ResourcePool + ); + if (stack) { + stack.Applications = stack.Applications.filter( + (app) => app.Name !== application.Name + ); + } +} diff --git a/app/react/kubernetes/applications/autoscaling.service.ts b/app/react/kubernetes/applications/queries/useHorizontalPodAutoScaler.ts similarity index 64% rename from app/react/kubernetes/applications/autoscaling.service.ts rename to app/react/kubernetes/applications/queries/useHorizontalPodAutoScaler.ts index 37aed7d56..6c3ca1669 100644 --- a/app/react/kubernetes/applications/autoscaling.service.ts +++ b/app/react/kubernetes/applications/queries/useHorizontalPodAutoScaler.ts @@ -1,17 +1,14 @@ -import { - HorizontalPodAutoscaler, - HorizontalPodAutoscalerList, -} from 'kubernetes-types/autoscaling/v1'; +import { HorizontalPodAutoscaler } from 'kubernetes-types/autoscaling/v1'; import { useQuery } from '@tanstack/react-query'; import axios from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { withError } from '@/react-tools/react-query'; +import { withGlobalError } from '@/react-tools/react-query'; -import { parseKubernetesAxiosError } from '../axiosError'; +import { parseKubernetesAxiosError } from '../../axiosError'; // when yaml is set to true, the expected return type is a string -export function useHorizontalAutoScalarQuery< +export function useHorizontalPodAutoScaler< T extends HorizontalPodAutoscaler | string = HorizontalPodAutoscaler, >( environmentId: EnvironmentId, @@ -39,28 +36,13 @@ export function useHorizontalAutoScalarQuery< options ) : undefined, - { ...withError('Unable to get horizontal pod autoscaler'), enabled: !!name } + { + ...withGlobalError('Unable to get horizontal pod autoscaler'), + enabled: !!name, + } ); } -export async function getNamespaceHorizontalPodAutoscalers( - environmentId: EnvironmentId, - namespace: string -) { - try { - const { data: autoScalarList } = - await axios.get( - `/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers` - ); - return autoScalarList.items; - } catch (e) { - throw parseKubernetesAxiosError( - e, - 'Unable to retrieve horizontal pod autoscalers' - ); - } -} - export async function getNamespaceHorizontalPodAutoscaler< T extends HorizontalPodAutoscaler | string = HorizontalPodAutoscaler, >( diff --git a/app/react/kubernetes/applications/queries/usePatchApplicationMutation.ts b/app/react/kubernetes/applications/queries/usePatchApplicationMutation.ts new file mode 100644 index 000000000..f86847c1c --- /dev/null +++ b/app/react/kubernetes/applications/queries/usePatchApplicationMutation.ts @@ -0,0 +1,155 @@ +import { useMutation } from '@tanstack/react-query'; +import { Deployment, DaemonSet, StatefulSet } from 'kubernetes-types/apps/v1'; +import { Pod } from 'kubernetes-types/core/v1'; + +import { queryClient } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import type { AppKind, ApplicationPatch, Application } from '../types'; +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { queryKeys } from './query-keys'; + +// useQuery to patch an application by environmentId, namespace, name and patch payload +export function usePatchApplicationMutation( + environmentId: EnvironmentId, + namespace: string, + name: string +) { + return useMutation( + ({ + appKind, + patch, + contentType = 'application/json-patch+json', + }: { + appKind: AppKind; + patch: ApplicationPatch; + contentType?: + | 'application/json-patch+json' + | 'application/strategic-merge-patch+json'; + }) => + patchApplication( + environmentId, + namespace, + appKind, + name, + patch, + contentType + ), + { + 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 + } + ); +} + +async function patchApplication( + environmentId: EnvironmentId, + namespace: string, + appKind: AppKind, + name: string, + patch: ApplicationPatch, + contentType: string = 'application/json-patch+json' +) { + switch (appKind) { + case 'Deployment': + return patchApplicationByKind( + environmentId, + namespace, + appKind, + name, + patch, + contentType + ); + case 'DaemonSet': + return patchApplicationByKind( + environmentId, + namespace, + appKind, + name, + patch, + contentType + ); + case 'StatefulSet': + return patchApplicationByKind( + environmentId, + namespace, + appKind, + name, + patch, + contentType + ); + case 'Pod': + return patchPod(environmentId, namespace, name, patch); + default: + throw new Error(`Unknown application kind ${appKind}`); + } +} + +async function patchApplicationByKind( + environmentId: EnvironmentId, + namespace: string, + appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet', + name: string, + patch: ApplicationPatch, + contentType = 'application/json-patch+json' +) { + try { + const res = await axios.patch( + buildUrl(environmentId, namespace, `${appKind}s`, name), + patch, + { + headers: { + 'Content-Type': contentType, + }, + } + ); + return res; + } catch (e) { + throw parseKubernetesAxiosError(e, 'Unable to patch application'); + } +} + +async function patchPod( + environmentId: EnvironmentId, + namespace: string, + name: string, + patch: ApplicationPatch +) { + try { + return await axios.patch( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods/${name}`, + patch, + { + headers: { + 'Content-Type': 'application/json-patch+json', + }, + } + ); + } catch (e) { + throw parseAxiosError(e, 'Unable to update pod'); + } +} + +function buildUrl( + environmentId: EnvironmentId, + namespace: string, + appKind: + | 'Deployments' + | 'DaemonSets' + | 'StatefulSets' + | 'ReplicaSets' + | 'ControllerRevisions', + name?: string +) { + let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`; + if (name) { + baseUrl += `/${name}`; + } + return baseUrl; +} diff --git a/app/react/kubernetes/applications/queries/useRedeployApplicationMutation.ts b/app/react/kubernetes/applications/queries/useRedeployApplicationMutation.ts new file mode 100644 index 000000000..35c9c2627 --- /dev/null +++ b/app/react/kubernetes/applications/queries/useRedeployApplicationMutation.ts @@ -0,0 +1,70 @@ +import { useMutation } from '@tanstack/react-query'; +import { Pod } from 'kubernetes-types/core/v1'; + +import { queryClient, withGlobalError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios from '@/portainer/services/axios'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +import { queryKeys } from './query-keys'; +import { getPods } from './getPods'; + +// 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 getPods(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) + ); + }, + ...withGlobalError('Unable to redeploy application'), + } + ); +} + +async function deletePod( + environmentId: EnvironmentId, + namespace: string, + name: string +) { + try { + return await axios.delete(buildUrl(environmentId, namespace, name)); + } catch (e) { + throw parseKubernetesAxiosError(e as Error, 'Unable to delete pod'); + } +} + +function buildUrl( + environmentId: EnvironmentId, + namespace: string, + name?: string +) { + let baseUrl = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`; + if (name) { + baseUrl += `/${name}`; + } + return baseUrl; +} diff --git a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/NodeApplicationsDatatable.tsx b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/NodeApplicationsDatatable.tsx index e4b6bfafc..788345d65 100644 --- a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/NodeApplicationsDatatable.tsx +++ b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/NodeApplicationsDatatable.tsx @@ -2,7 +2,7 @@ import { useCurrentStateAndParams } from '@uirouter/react'; import LaptopCode from '@/assets/ico/laptop-code.svg?c'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { useApplications } from '@/react/kubernetes/applications/application.queries'; +import { useApplications } from '@/react/kubernetes/applications/queries/useApplications'; import { Datatable, TableSettingsMenu } from '@@/datatables'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';