refactor(apps): migrate applications view to react [r8s-124] (#28)

pull/12336/head
Ali 2024-10-25 12:28:05 +13:00 committed by GitHub
parent cc75167437
commit 959c527be7
42 changed files with 1378 additions and 1293 deletions

View File

@ -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',

View File

@ -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;

View File

@ -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)), [

View File

@ -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(

View File

@ -1,59 +0,0 @@
<page-header ng-if="ctrl.state.viewReady" title="'Application list'" breadcrumbs="['Applications']" reload="true"></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<div class="row">
<div class="col-sm-12" data-cy="k8sApp-appList">
<rd-widget>
<rd-widget-body classes="no-padding">
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality">
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
<uib-tab-heading class="vertical-center"> <pr-icon icon="'box'"></pr-icon> Applications </uib-tab-heading>
<kubernetes-applications-datatable
dataset="ctrl.state.applications"
on-refresh="(ctrl.getApplications)"
namespaces="ctrl.state.namespaces"
namespace="ctrl.state.namespaceName"
on-namespace-change="(ctrl.onChangeNamespaceDropdown)"
is-loading="ctrl.state.isAppsLoading"
on-remove="(ctrl.removeAction)"
hide-stacks="ctrl.deploymentOptions.hideStacksFunctionality"
>
</kubernetes-applications-datatable>
</uib-tab>
<uib-tab index="2" classes="btn-sm" select="ctrl.selectTab(2)">
<uib-tab-heading class="vertical-center"> <pr-icon icon="'list'"></pr-icon> Stacks </uib-tab-heading>
<kubernetes-applications-stacks-datatable
dataset="ctrl.state.stacks"
on-refresh="(ctrl.getApplications)"
on-remove="(ctrl.removeStacksAction)"
namespaces="ctrl.state.namespaces"
namespace="ctrl.state.namespaceName"
is-loading="ctrl.state.isAppsLoading"
on-namespace-change="(ctrl.onChangeNamespaceDropdown)"
show-system="ctrl.state.isSystemResources"
set-system-resources="(ctrl.setSystemResources)"
>
</kubernetes-applications-stacks-datatable>
</uib-tab>
</uib-tabset>
<kubernetes-applications-datatable
ng-if="ctrl.deploymentOptions.hideStacksFunctionality"
dataset="ctrl.state.applications"
on-refresh="(ctrl.getApplications)"
namespaces="ctrl.state.namespaces"
namespace="ctrl.state.namespaceName"
on-namespace-change="(ctrl.onChangeNamespaceDropdown)"
is-loading="ctrl.state.isAppsLoading"
on-remove="(ctrl.removeAction)"
hide-stacks="ctrl.deploymentOptions.hideStacksFunctionality"
>
</kubernetes-applications-datatable>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

@ -1,9 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesApplicationsView', {
templateUrl: './applications.html',
controller: 'KubernetesApplicationsController',
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
endpoint: '<',
},
});

View File

@ -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);

View File

@ -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<string>(
useHorizontalPodAutoScaler<string>(
environmentId,
namespace,
autoScalar?.metadata?.name || '',

View File

@ -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';

View File

@ -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;

View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 });

View File

@ -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();

View File

@ -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 {}

View File

@ -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 });

View File

@ -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<Namespace>;
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 (
<ExpandableDatatable
data-cy="k8sApp-appTable"
noWidget
dataset={filteredApplications ?? []}
settingsManager={tableState}
columns={columns}
title="Applications"
titleIcon={BoxIcon}
isLoading={applicationsQuery.isLoading}
disableSelect={!hasWriteAuthQuery.authorized}
disableSelect={!hasWriteAuth}
isRowSelectable={(row) =>
!isSystemNamespace(row.original.ResourcePool, namespaceListQuery.data)
}
@ -91,25 +92,22 @@ export function ApplicationsDatatable({
<SubRow
item={row.original}
hideStacks={hideStacks}
areSecretsRestricted={
envQuery.data?.Kubernetes.Configuration.RestrictSecrets || false
}
areSecretsRestricted={!!restrictSecretsQuery.data}
/>
)}
renderTableActions={(selectedItems) =>
hasWriteAuthQuery.authorized && (
hasWriteAuth && (
<>
<DeleteButton
data-cy="k8sApp-removeAppButton"
disabled={selectedItems.length === 0}
isLoading={removeApplicationsMutation.isLoading}
confirmMessage="Do you want to remove the selected application(s)?"
onConfirmed={() => onRemove(selectedItems)}
onConfirmed={() => handleRemoveApplications(selectedItems)}
/>
<AddButton data-cy="k8sApp-addApplicationButton" color="secondary">
Add with form
</AddButton>
<CreateFromManifestButton data-cy="k8sApp-deployFromManifestButton" />
</>
)
@ -123,18 +121,16 @@ export function ApplicationsDatatable({
<div className="w-full">
<div className="min-w-[140px] float-right">
<NamespaceFilter
namespaces={namespaces}
value={namespace}
onChange={onNamespaceChange}
namespaces={namespaceListQuery.data ?? []}
value={tableState.namespace}
onChange={tableState.setNamespace}
showSystem={tableState.showSystemResources}
/>
</div>
<div className="space-y-2">
<SystemResourceDescription
showSystemResources={tableState.showSystemResources}
/>
<div className="w-fit">
<HelmInsightsBox />
</div>
@ -143,6 +139,14 @@ export function ApplicationsDatatable({
}
/>
);
function handleRemoveApplications(applications: ApplicationRowData[]) {
removeApplicationsMutation.mutate(applications, {
onSuccess: () => {
router.stateService.reload();
},
});
}
}
function useApplicationsRowData(

View File

@ -0,0 +1 @@
export { ApplicationsDatatable } from './ApplicationsDatatable';

View File

@ -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<Stack>): void;
namespace?: string;
namespaces: Array<Namespace>;
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 (
<ExpandableDatatable
@ -68,18 +62,17 @@ export function ApplicationsStacksDatatable({
isLoading={applicationsQuery.isLoading || namespaceListQuery.isLoading}
columns={columns}
settingsManager={tableState}
disableSelect={!authorized}
disableSelect={!hasWriteAuth}
renderSubRow={(row) => (
<SubRows stack={row.original} span={row.getVisibleCells().length} />
)}
noWidget
description={
<div className="w-full">
<div className="float-right mr-2 min-w-[140px]">
<NamespaceFilter
namespaces={namespaces}
value={namespace}
onChange={onNamespaceChange}
namespaces={namespaceListQuery.data ?? []}
value={tableState.namespace}
onChange={tableState.setNamespace}
showSystem={tableState.showSystemResources}
/>
</div>
@ -92,7 +85,14 @@ export function ApplicationsStacksDatatable({
</div>
}
renderTableActions={(selectedItems) => (
<TableActions selectedItems={selectedItems} onRemove={onRemove} />
<Authorized authorizations="K8sApplicationsW">
<DeleteButton
confirmMessage="Are you sure that you want to remove the selected stack(s) ? This will remove all the applications associated to the stack(s)."
disabled={selectedItems.length === 0}
onConfirmed={() => handleRemoveStacks(selectedItems)}
data-cy="k8sApp-removeStackButton"
/>
</Authorized>
)}
renderTableSettings={() => (
<TableSettingsMenu>
@ -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();
},
});
}
}

View File

@ -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;

View File

@ -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<Stack>;
onRemove: (selectedItems: Array<Stack>) => void;
}) {
return (
<Authorized authorizations="K8sApplicationsW">
<DeleteButton
confirmMessage="Are you sure that you want to remove the selected stack(s) ? This will remove all the applications associated to the stack(s)."
disabled={selectedItems.length === 0}
onConfirmed={() => onRemove(selectedItems)}
data-cy="k8sApp-removeStackButton"
/>
</Authorized>
);
}

View File

@ -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;

View File

@ -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: <ApplicationsDatatable tableState={tableState} />,
selectedTabParam: 'applications',
},
{
name: 'Stacks',
icon: List,
widget: <ApplicationsStacksDatatable tableState={tableState} />,
selectedTabParam: 'stacks',
},
];
const currentTabIndex = findSelectedTabIndex(
useCurrentStateAndParams(),
tabs
);
return (
<>
<PageHeader title="Application list" breadcrumbs="Applications" reload />
{hideStacks ? (
<ApplicationsDatatable tableState={tableState} hideStacks />
) : (
<>
<WidgetTabs tabs={tabs} currentTabIndex={currentTabIndex} />
<div className="content">{tabs[currentTabIndex].widget}</div>
</>
)}
</>
);
}

View File

@ -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<T extends NamespaceSelectSettings>(
set: ZustandSetFunc<T>,
namespace = 'default'
): NamespaceSelectSettings {
return {
namespace,
setNamespace: (namespace: string) => set((s) => ({ ...s, namespace })),
};
}
export function createStore<T extends ApplicationsTableSettings>(
storageKey: string,
initialSortBy?: string | { id: string; desc: boolean },
create: (
set: ZustandSetFunc<T>
) => Omit<T, keyof ApplicationsTableSettings> = () => ({}) as T
) {
return createPersistedStore<T>(
storageKey,
initialSortBy,
(set) =>
({
...refreshableSettings(set),
...systemResourcesSettings(set),
...namespaceSelectSettings(set),
...create(set),
}) as T
);
}
export function useKubeAppsTableStore<T extends ApplicationsTableSettings>(
...args: Parameters<typeof createStore<T>>
) {
const [store] = useState(() => createStore(...args));
return useTableState(store, args[0]);
}

View File

@ -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<T extends Application | string = Application>(
environmentId: EnvironmentId,
namespace: string,
name: string,
appKind?: AppKind,
options?: { autoRefreshRate?: number; yaml?: boolean }
): UseQueryResult<T> {
return useQuery(
queryKeys.application(environmentId, namespace, name, options?.yaml),
() =>
getApplication<T>(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>('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>('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<PodList>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`,
{
params: {
labelSelector,
},
}
);
const items = (data.items || []).map(
(pod) =>
<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<K8sApplication[]>(
`/kubernetes/${environmentId}/applications`,
{ params }
);
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to retrieve applications');
}
}

View File

@ -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<T>(
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<Deployment>(
environmentId,
namespace,
'Deployment',
name,
yaml
),
getApplicationByKind<DaemonSet>(
environmentId,
namespace,
'DaemonSet',
name,
yaml
),
getApplicationByKind<StatefulSet>(
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<Deployment>(
environmentId,
namespace,
appKind,
name,
patch,
contentType
);
case 'DaemonSet':
return patchApplicationByKind<DaemonSet>(
environmentId,
namespace,
appKind,
name,
patch,
contentType
);
case 'StatefulSet':
return patchApplicationByKind<StatefulSet>(
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<T extends Application>(
environmentId: EnvironmentId,
namespace: string,
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
name: string,
patch: ApplicationPatch,
contentType = 'application/json-patch+json'
) {
try {
const res = await axios.patch<T>(
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<T>(
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<ReplicaSetList>(
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<ControllerRevisionList>(
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;
}

View File

@ -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<T extends Pod | string = Pod>(
environmentId: EnvironmentId,
namespace: string,
name: string,
yaml?: boolean
) {
try {
const { data } = await axios.get<T>(
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<Pod>(
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<Pod>(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;
}

View File

@ -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<PodList>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`,
{
params: {
labelSelector,
},
}
);
const items = (data.items || []).map(
(pod) =>
<Pod>{
...pod,
kind: 'Pod',
apiVersion: data.apiVersion,
}
);
return items;
} catch (e) {
throw parseKubernetesAxiosError(
e,
`Unable to retrieve Pods in namespace '${namespace}'`
);
}
}

View File

@ -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,
};

View File

@ -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<T extends Application | string = Application>(
environmentId: EnvironmentId,
namespace: string,
name: string,
appKind?: AppKind,
options?: { autoRefreshRate?: number; yaml?: boolean }
): UseQueryResult<T> {
return useQuery(
queryKeys.application(environmentId, namespace, name, options?.yaml),
() =>
getApplication<T>(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<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<T>(
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<Deployment>(
environmentId,
namespace,
'Deployment',
name,
yaml
),
getApplicationByKind<DaemonSet>(
environmentId,
namespace,
'DaemonSet',
name,
yaml
),
getApplicationByKind<StatefulSet>(
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<T extends Pod | string = Pod>(
environmentId: EnvironmentId,
namespace: string,
name: string,
yaml?: boolean
) {
try {
const { data } = await axios.get<T>(
`/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<T>(
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;
}

View File

@ -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<HorizontalPodAutoscalerList>(
`/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers`
);
return autoScalarList.items;
} catch (e) {
throw parseKubernetesAxiosError(
e,
'Unable to retrieve horizontal pod autoscalers'
);
}
}

View File

@ -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>('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;
},
}
);
}

View File

@ -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<ReplicaSetList>(
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<ControllerRevisionList>(
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;
}

View File

@ -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>('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,
}
);
}

View File

@ -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<Application[]>(
`/kubernetes/${environmentId}/applications`,
{ params }
);
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to retrieve applications');
}
}

View File

@ -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
);
}
}

View File

@ -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<HorizontalPodAutoscalerList>(
`/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,
>(

View File

@ -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<Deployment>(
environmentId,
namespace,
appKind,
name,
patch,
contentType
);
case 'DaemonSet':
return patchApplicationByKind<DaemonSet>(
environmentId,
namespace,
appKind,
name,
patch,
contentType
);
case 'StatefulSet':
return patchApplicationByKind<StatefulSet>(
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<T extends Application>(
environmentId: EnvironmentId,
namespace: string,
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
name: string,
patch: ApplicationPatch,
contentType = 'application/json-patch+json'
) {
try {
const res = await axios.patch<T>(
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<Pod>(
`/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;
}

View File

@ -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<Pod>(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;
}

View File

@ -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';