refactor(app): details widget migration [EE-5352] (#8886)

pull/9013/head
Ali 2 years ago committed by GitHub
parent fdd79cece8
commit af77e33993
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -15,6 +15,15 @@
// ],
// "description": "Log output to console"
// }
"React Named Export Component": {
"prefix": "rnec",
"body": [
"export function $TM_FILENAME_BASE() {",
" return <div>$TM_FILENAME_BASE</div>;",
"}"
],
"description": "React Named Export Component"
},
"Component": {
"scope": "javascript",
"prefix": "mycomponent",

@ -2,7 +2,7 @@ import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withControlledInput } from '@/react-tools/withControlledInput';
import { StackContainersDatatable } from '@/react/docker/stacks/ItemView/StackContainersDatatable';
import { StackContainersDatatable } from '@/react/common/stacks/ItemView/StackContainersDatatable';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown';
import { TemplateListSortAngular } from '@/react/docker/app-templates/TemplateListSort';

@ -1,76 +0,0 @@
import angular from 'angular';
import _ from 'lodash-es';
import PortainerError from 'Portainer/error';
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import { KubernetesSystem_DefaultDeploymentUniqueLabelKey, KubernetesSystem_AnnotationsToSkip } from 'Kubernetes/models/history/models';
class KubernetesApplicationRollbackHelper {
static getPatchPayload(application, targetRevision) {
let result;
switch (application.ApplicationType) {
case KubernetesApplicationTypes.DEPLOYMENT:
result = KubernetesApplicationRollbackHelper._getDeploymentPayload(application, targetRevision);
break;
case KubernetesApplicationTypes.DAEMONSET:
result = KubernetesApplicationRollbackHelper._getDaemonSetPayload(application, targetRevision);
break;
case KubernetesApplicationTypes.STATEFULSET:
result = KubernetesApplicationRollbackHelper._getStatefulSetPayload(application, targetRevision);
break;
default:
throw new PortainerError('Unable to determine which association to use to convert patch');
}
return result;
}
static _getDeploymentPayload(deploymentApp, targetRevision) {
const target = angular.copy(targetRevision);
const deployment = deploymentApp.Raw;
// remove hash label before patching back into the deployment
delete target.spec.template.metadata.labels[KubernetesSystem_DefaultDeploymentUniqueLabelKey];
// compute deployment annotations
const annotations = {};
_.forEach(KubernetesSystem_AnnotationsToSkip, (_, k) => {
const v = deployment.metadata.annotations[k];
if (v) {
annotations[k] = v;
}
});
_.forEach(target.metadata.annotations, (v, k) => {
if (!KubernetesSystem_AnnotationsToSkip[k]) {
annotations[k] = v;
}
});
// Create a patch of the Deployment that replaces spec.template
const patch = [
{
op: 'replace',
path: '/spec/template',
value: target.spec.template,
},
{
op: 'replace',
path: '/metadata/annotations',
value: annotations,
},
];
return patch;
}
static _getDaemonSetPayload(daemonSet, targetRevision) {
void daemonSet;
return targetRevision.data;
}
static _getStatefulSetPayload(statefulSet, targetRevision) {
void statefulSet;
return targetRevision.data;
}
}
export default KubernetesApplicationRollbackHelper;

@ -1,27 +0,0 @@
import _ from 'lodash-es';
class KubernetesDaemonSetHistoryHelper {
static _isControlledBy(daemonSet) {
return (item) => _.find(item.metadata.ownerReferences, { uid: daemonSet.metadata.uid }) !== undefined;
}
static filterOwnedRevisions(crList, daemonSet) {
// filter ControllerRevisions that has the same selector as the DaemonSet
// NOTE : this should be done in HTTP request based on daemonSet.spec.selector.matchLabels
// instead of getting all CR and filtering them here
const sameLabelsCR = _.filter(crList, ['metadata.labels', daemonSet.spec.selector.matchLabels]);
// Only include the RS whose ControllerRef matches the DaemonSet.
const controlledCR = _.filter(sameLabelsCR, KubernetesDaemonSetHistoryHelper._isControlledBy(daemonSet));
// sorts the list of ControllerRevisions by revision, using the creationTimestamp as a tie breaker (old to new)
const sortedList = _.sortBy(controlledCR, ['revision', 'metadata.creationTimestamp']);
return sortedList;
}
// getCurrentRS returns the newest CR the given daemonSet targets (latest version)
static getCurrentRevision(crList) {
const current = _.last(crList);
return current;
}
}
export default KubernetesDaemonSetHistoryHelper;

@ -1,56 +0,0 @@
import _ from 'lodash-es';
import angular from 'angular';
import { KubernetesSystem_DefaultDeploymentUniqueLabelKey, KubernetesSystem_RevisionAnnotation } from 'Kubernetes/models/history/models';
class KubernetesDeploymentHistoryHelper {
static _isControlledBy(deployment) {
return (item) => _.find(item.metadata.ownerReferences, { uid: deployment.metadata.uid }) !== undefined;
}
static filterOwnedRevisions(rsList, deployment) {
// filter RS that has the same selector as the Deployment
// NOTE : this should be done in HTTP request based on deployment.spec.selector
// instead of getting all RS and filtering them here
const sameLabelsRS = _.filter(rsList, ['spec.selector', deployment.spec.selector]);
// Only include the RS whose ControllerRef matches the Deployment.
const controlledRS = _.filter(sameLabelsRS, KubernetesDeploymentHistoryHelper._isControlledBy(deployment));
// sorts the list of ReplicaSet by creation timestamp, using the names as a tie breaker (old to new)
const sortedList = _.sortBy(controlledRS, ['metadata.creationTimestamp', 'metadata.name']);
return sortedList;
}
// getCurrentRS returns the new RS the given deployment targets (the one with the same pod template).
static getCurrentRevision(rsListOriginal, deployment) {
const rsList = angular.copy(rsListOriginal);
// In rare cases, such as after cluster upgrades, Deployment may end up with
// having more than one new ReplicaSets that have the same template as its template,
// see https://github.com/kubernetes/kubernetes/issues/40415
// We deterministically choose the oldest new ReplicaSet (first match)
const current = _.find(rsList, (item) => {
// returns true if two given template.spec are equal, ignoring the diff in value of Labels[pod-template-hash]
// We ignore pod-template-hash because:
// 1. The hash result would be different upon podTemplateSpec API changes
// (e.g. the addition of a new field will cause the hash code to change)
// 2. The deployment template won't have hash labels
delete item.spec.template.metadata.labels[KubernetesSystem_DefaultDeploymentUniqueLabelKey];
return _.isEqual(deployment.spec.template, item.spec.template);
});
current.revision = current.metadata.annotations[KubernetesSystem_RevisionAnnotation];
return current;
}
// filters the RSList to drop all RS that have never been a version of the Deployment
// also add the revision as a field inside the RS
// Note: this should not impact rollback process as we only patch
// metadata.annotations and spec.template
static filterVersionedRevisions(rsList) {
const filteredRS = _.filter(rsList, (item) => item.metadata.annotations[KubernetesSystem_RevisionAnnotation] !== undefined);
return _.map(filteredRS, (item) => {
item.revision = item.metadata.annotations[KubernetesSystem_RevisionAnnotation];
return item;
});
}
}
export default KubernetesDeploymentHistoryHelper;

@ -1,50 +0,0 @@
import _ from 'lodash-es';
import PortainerError from 'Portainer/error';
import KubernetesDeploymentHistoryHelper from 'Kubernetes/helpers/history/deployment';
import KubernetesDaemonSetHistoryHelper from 'Kubernetes/helpers/history/daemonset';
import KubernetesStatefulSetHistoryHelper from 'Kubernetes/helpers/history/statefulset';
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
class KubernetesHistoryHelper {
static getRevisions(rawRevisions, application) {
let currentRevision, revisionsList;
switch (application.ApplicationType) {
case KubernetesApplicationTypes.DEPLOYMENT:
[currentRevision, revisionsList] = KubernetesHistoryHelper._getDeploymentRevisions(rawRevisions, application.Raw);
break;
case KubernetesApplicationTypes.DAEMONSET:
[currentRevision, revisionsList] = KubernetesHistoryHelper._getDaemonSetRevisions(rawRevisions, application.Raw);
break;
case KubernetesApplicationTypes.STATEFULSET:
[currentRevision, revisionsList] = KubernetesHistoryHelper._getStatefulSetRevisions(rawRevisions, application.Raw);
break;
default:
throw new PortainerError('Unable to determine which association to use to get revisions');
}
revisionsList = _.sortBy(revisionsList, 'revision');
return [currentRevision, revisionsList];
}
static _getDeploymentRevisions(rsList, deployment) {
const appRS = KubernetesDeploymentHistoryHelper.filterOwnedRevisions(rsList, deployment);
const currentRS = KubernetesDeploymentHistoryHelper.getCurrentRevision(appRS, deployment);
const versionedRS = KubernetesDeploymentHistoryHelper.filterVersionedRevisions(appRS);
return [currentRS, versionedRS];
}
static _getDaemonSetRevisions(crList, daemonSet) {
const appCR = KubernetesDaemonSetHistoryHelper.filterOwnedRevisions(crList, daemonSet);
const currentCR = KubernetesDaemonSetHistoryHelper.getCurrentRevision(appCR, daemonSet);
return [currentCR, appCR];
}
static _getStatefulSetRevisions(crList, statefulSet) {
const appCR = KubernetesStatefulSetHistoryHelper.filterOwnedRevisions(crList, statefulSet);
const currentCR = KubernetesStatefulSetHistoryHelper.getCurrentRevision(appCR, statefulSet);
return [currentCR, appCR];
}
}
export default KubernetesHistoryHelper;

@ -1,27 +0,0 @@
import _ from 'lodash-es';
class KubernetesStatefulSetHistoryHelper {
static _isControlledBy(statefulSet) {
return (item) => _.find(item.metadata.ownerReferences, { uid: statefulSet.metadata.uid }) !== undefined;
}
static filterOwnedRevisions(crList, statefulSet) {
// filter ControllerRevisions that has the same selector as the StatefulSet
// NOTE : this should be done in HTTP request based on statefulSet.spec.selector.matchLabels
// instead of getting all CR and filtering them here
const sameLabelsCR = _.filter(crList, ['metadata.labels', statefulSet.spec.selector.matchLabels]);
// Only include the RS whose ControllerRef matches the StatefulSet.
const controlledCR = _.filter(sameLabelsCR, KubernetesStatefulSetHistoryHelper._isControlledBy(statefulSet));
// sorts the list of ControllerRevisions by revision, using the creationTimestamp as a tie breaker (old to new)
const sortedList = _.sortBy(controlledCR, ['revision', 'metadata.creationTimestamp']);
return sortedList;
}
// getCurrentRS returns the newest CR the given statefulSet targets (latest version)
static getCurrentRevision(crList) {
const current = _.last(crList);
return current;
}
}
export default KubernetesStatefulSetHistoryHelper;

@ -112,23 +112,6 @@ class KubernetesHorizontalPodAutoScalerService {
delete(horizontalPodAutoScaler) {
return this.$async(this.deleteAsync, horizontalPodAutoScaler);
}
// /**
// * ROLLBACK
// */
// async rollbackAsync(namespace, name, payload) {
// try {
// const params = new KubernetesCommonParams();
// params.id = name;
// await this.KubernetesHorizontalPodAutoScalers(namespace).rollback(params, payload).$promise;
// } catch (err) {
// throw new PortainerError('Unable to rollback horizontalPodAutoScaler', err);
// }
// }
// rollback(namespace, name, payload) {
// return this.$async(this.rollbackAsync, namespace, name, payload);
// }
}
export default KubernetesHorizontalPodAutoScalerService;

@ -35,8 +35,6 @@ const _KubernetesApplication = Object.freeze({
TotalPodsCount: 0,
Yaml: '',
Note: '',
Revisions: undefined,
CurrentRevision: undefined,
Raw: undefined, // only filled when inspecting app details / create / edit view (never filled in multiple-apps views)
AutoScaler: undefined, // only filled if the application has an HorizontalPodAutoScaler bound to it
});

@ -1,7 +1,6 @@
export const KubernetesSystem_DefaultDeploymentUniqueLabelKey = 'pod-template-hash';
export const KubernetesSystem_RevisionAnnotation = 'deployment.kubernetes.io/revision';
export const KubernetesSystem_RevisionHistoryAnnotation = 'deployment.kubernetes.io/revision-history';
export const KubernetesSystem_ChangeCauseAnnotation = 'kubernetes.io/change-cause';
export const KubernetesSystem_DesiredReplicasAnnotation = 'deployment.kubernetes.io/desired-replicas';
export const KubernetesSystem_MaxReplicasAnnotation = 'deployment.kubernetes.io/max-replicas';

@ -8,9 +8,12 @@ import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/Acce
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
import { KubeApplicationDeploymentTypeSelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationDeploymentTypeSelector';
import { ApplicationSummaryWidget } from '@/react/kubernetes/applications/DetailsView';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import {
ApplicationSummaryWidget,
ApplicationDetailsWidget,
} from '@/react/kubernetes/applications/DetailsView';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
export const componentsModule = angular
@ -93,4 +96,11 @@ export const componentsModule = angular
withUIRouter(withReactQuery(withUserProvider(ApplicationSummaryWidget))),
[]
)
)
.component(
'applicationDetailsWidget',
r2a(
withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))),
[]
)
).name;

@ -4,7 +4,6 @@ import PortainerError from 'Portainer/error';
import { KubernetesApplication, KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesApplicationRollbackHelper from 'Kubernetes/helpers/application/rollback';
import KubernetesApplicationConverter from 'Kubernetes/converters/application';
import { KubernetesDeployment } from 'Kubernetes/models/deployment/models';
import { KubernetesStatefulSet } from 'Kubernetes/models/stateful-set/models';
@ -29,7 +28,6 @@ class KubernetesApplicationService {
KubernetesPersistentVolumeClaimService,
KubernetesNamespaceService,
KubernetesPodService,
KubernetesHistoryService,
KubernetesHorizontalPodAutoScalerService,
KubernetesIngressService
) {
@ -43,7 +41,6 @@ class KubernetesApplicationService {
this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService;
this.KubernetesNamespaceService = KubernetesNamespaceService;
this.KubernetesPodService = KubernetesPodService;
this.KubernetesHistoryService = KubernetesHistoryService;
this.KubernetesHorizontalPodAutoScalerService = KubernetesHorizontalPodAutoScalerService;
this.KubernetesIngressService = KubernetesIngressService;
@ -52,7 +49,6 @@ class KubernetesApplicationService {
this.createAsync = this.createAsync.bind(this);
this.patchAsync = this.patchAsync.bind(this);
this.patchPartialAsync = this.patchPartialAsync.bind(this);
this.rollbackAsync = this.rollbackAsync.bind(this);
this.deleteAsync = this.deleteAsync.bind(this);
}
/* #endregion */
@ -123,8 +119,6 @@ class KubernetesApplicationService {
application.AutoScaler = scaler;
application.Ingresses = ingresses;
await this.KubernetesHistoryService.get(application);
if (service.Yaml) {
application.Yaml += '---\n' + service.Yaml;
}
@ -428,18 +422,6 @@ class KubernetesApplicationService {
return this.$async(this.deleteAsync, application);
}
/* #endregion */
/* #region ROLLBACK */
async rollbackAsync(application, targetRevision) {
const payload = KubernetesApplicationRollbackHelper.getPatchPayload(application, targetRevision);
const apiService = this._getApplicationApiService(application);
await apiService.rollback(application.ResourcePool, application.Name, payload);
}
rollback(application, targetRevision) {
return this.$async(this.rollbackAsync, application, targetRevision);
}
/* #endregion */
}
export default KubernetesApplicationService;

@ -13,7 +13,6 @@ class KubernetesDaemonSetService {
this.getAllAsync = this.getAllAsync.bind(this);
this.createAsync = this.createAsync.bind(this);
this.patchAsync = this.patchAsync.bind(this);
this.rollbackAsync = this.rollbackAsync.bind(this);
this.deleteAsync = this.deleteAsync.bind(this);
}
@ -110,23 +109,6 @@ class KubernetesDaemonSetService {
delete(daemonSet) {
return this.$async(this.deleteAsync, daemonSet);
}
/**
* ROLLBACK
*/
async rollbackAsync(namespace, name, payload) {
try {
const params = new KubernetesCommonParams();
params.id = name;
await this.KubernetesDaemonSets(namespace).rollback(params, payload).$promise;
} catch (err) {
throw new PortainerError('Unable to rollback daemonset', err);
}
}
rollback(namespace, name, payload) {
return this.$async(this.rollbackAsync, namespace, name, payload);
}
}
export default KubernetesDaemonSetService;

@ -13,7 +13,6 @@ class KubernetesDeploymentService {
this.getAllAsync = this.getAllAsync.bind(this);
this.createAsync = this.createAsync.bind(this);
this.patchAsync = this.patchAsync.bind(this);
this.rollbackAsync = this.rollbackAsync.bind(this);
this.deleteAsync = this.deleteAsync.bind(this);
}
@ -110,23 +109,6 @@ class KubernetesDeploymentService {
delete(deployment) {
return this.$async(this.deleteAsync, deployment);
}
/**
* ROLLBACK
*/
async rollbackAsync(namespace, name, payload) {
try {
const params = new KubernetesCommonParams();
params.id = name;
await this.KubernetesDeployments(namespace).rollback(params, payload).$promise;
} catch (err) {
throw new PortainerError('Unable to rollback deployment', err);
}
}
rollback(namespace, name, payload) {
return this.$async(this.rollbackAsync, namespace, name, payload);
}
}
export default KubernetesDeploymentService;

@ -1,58 +0,0 @@
import angular from 'angular';
import PortainerError from 'Portainer/error';
import KubernetesHistoryHelper from 'Kubernetes/helpers/history';
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
class KubernetesHistoryService {
/* @ngInject */
constructor($async, KubernetesReplicaSetService, KubernetesControllerRevisionService) {
this.$async = $async;
this.KubernetesReplicaSetService = KubernetesReplicaSetService;
this.KubernetesControllerRevisionService = KubernetesControllerRevisionService;
this.getAsync = this.getAsync.bind(this);
}
/**
* GET
*/
async getAsync(application) {
try {
const namespace = application.ResourcePool;
let rawRevisions;
switch (application.ApplicationType) {
case KubernetesApplicationTypes.DEPLOYMENT:
rawRevisions = await this.KubernetesReplicaSetService.get(namespace);
break;
case KubernetesApplicationTypes.DAEMONSET:
rawRevisions = await this.KubernetesControllerRevisionService.get(namespace);
break;
case KubernetesApplicationTypes.STATEFULSET:
rawRevisions = await this.KubernetesControllerRevisionService.get(namespace);
break;
case KubernetesApplicationTypes.POD:
rawRevisions = [];
break;
default:
throw new PortainerError('Unable to determine which association to use for history');
}
if (rawRevisions.length) {
const [currentRevision, revisionsList] = KubernetesHistoryHelper.getRevisions(rawRevisions, application);
application.CurrentRevision = currentRevision;
application.Revisions = revisionsList;
}
return application;
} catch (err) {
throw new PortainerError('', err);
}
}
get(application) {
return this.$async(this.getAsync, application);
}
}
export default KubernetesHistoryService;
angular.module('portainer.kubernetes').service('KubernetesHistoryService', KubernetesHistoryService);

@ -1,31 +0,0 @@
import angular from 'angular';
import PortainerError from 'Portainer/error';
class KubernetesReplicaSetService {
/* @ngInject */
constructor($async, KubernetesReplicaSets) {
this.$async = $async;
this.KubernetesReplicaSets = KubernetesReplicaSets;
this.getAllAsync = this.getAllAsync.bind(this);
}
/**
* GET
*/
async getAllAsync(namespace) {
try {
const data = await this.KubernetesReplicaSets(namespace).get().$promise;
return data.items;
} catch (err) {
throw new PortainerError('Unable to retrieve ReplicaSets', err);
}
}
get(namespace) {
return this.$async(this.getAllAsync, namespace);
}
}
export default KubernetesReplicaSetService;
angular.module('portainer.kubernetes').service('KubernetesReplicaSetService', KubernetesReplicaSetService);

@ -14,7 +14,6 @@ class KubernetesStatefulSetService {
this.getAllAsync = this.getAllAsync.bind(this);
this.createAsync = this.createAsync.bind(this);
this.patchAsync = this.patchAsync.bind(this);
this.rollbackAsync = this.rollbackAsync.bind(this);
this.deleteAsync = this.deleteAsync.bind(this);
}
@ -122,23 +121,6 @@ class KubernetesStatefulSetService {
delete(statefulSet) {
return this.$async(this.deleteAsync, statefulSet);
}
/**
* ROLLBACK
*/
async rollbackAsync(namespace, name, payload) {
try {
const params = new KubernetesCommonParams();
params.id = name;
await this.KubernetesStatefulSets(namespace).rollback(params, payload).$promise;
} catch (err) {
throw new PortainerError('Unable to rollback statefulSet', err);
}
}
rollback(namespace, name, payload) {
return this.$async(this.rollbackAsync, namespace, name, payload);
}
}
export default KubernetesStatefulSetService;

@ -86,308 +86,7 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<div ng-if="!ctrl.isSystemNamespace()" class="mb-4 flex items-center gap-1">
<button
ng-if="!ctrl.isExternalApplication()"
type="button"
class="btn btn-sm btn-light vertical-center ml-2"
ui-sref="kubernetes.applications.application.edit"
style="margin-left: 0"
data-cy="k8sAppDetail-editAppButton"
>
<pr-icon icon="'pencil'" class="mr-1"></pr-icon>{{ ctrl.stack.IsComposeFormat ? 'View this application' : 'Edit this application' }}
</button>
<button
authorization="K8sApplicationDetailsW"
ng-if="ctrl.isExternalApplication()"
type="button"
class="btn btn-sm btn-light ml-2"
ui-sref="kubernetes.applications.application.edit"
data-cy="k8sAppDetail-editAppButton"
>
<pr-icon icon="'pencil'" class-name="'mr-1'"></pr-icon>Edit external application
</button>
<be-teaser-button
icon="'refresh-cw'"
feature-id="ctrl.limitedFeature"
message="'A rolling restart of the application is performed.'"
heading="'Rolling restart'"
button-text="'Rolling restart'"
class-name="'be-tooltip-teaser'"
className="'be-tooltip-teaser'"
></be-teaser-button>
<button
ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD"
type="button"
class="btn btn-sm btn-light ml-2"
ng-click="ctrl.redeployApplication()"
data-cy="k8sAppDetail-redeployButton"
>
<pr-icon icon="'rotate-cw'" class="'mr-1'"></pr-icon>Redeploy
</button>
<button
ng-if="!ctrl.isExternalApplication()"
type="button"
class="btn btn-sm btn-light"
ng-click="ctrl.rollbackApplication()"
ng-disabled="ctrl.application.Revisions.length < 2 || ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
data-cy="k8sAppDetail-rollbackButton"
>
<pr-icon icon="'rotate-ccw'" class="mr-1"></pr-icon>Rollback to previous configuration
</button>
<a
ng-if="ctrl.isStack() && ctrl.stackFileContent"
class="btn btn-sm btn-primary space-left"
ui-sref="kubernetes.templates.custom.new({fileContent: ctrl.stackFileContent})"
>
<pr-icon icon="'plus'" class="mr-1"></pr-icon>Create template from application
</a>
</div>
<!-- ACCESSING APPLICATION -->
<div class="text-muted" style="margin-bottom: 15px"> <pr-icon icon="'external-link'" class="mr-1"></pr-icon>Accessing the application </div>
<div class="small text-muted" ng-if="ctrl.application.PublishedPorts.length === 0" style="margin-bottom: 15px">
<pr-icon icon="'info'" mode="'primary'" class="mr-1"></pr-icon>This application is not exposing any port.
</div>
<div ng-if="ctrl.application.Services.length !== 0">
<!-- Services notice -->
<div>
<div class="small text-muted">
<p> <pr-icon icon="'info'" mode="'primary'" class="mr-1"></pr-icon>This application is exposed through service(s) as below: </p>
</div>
</div>
<!-- table -->
<kubernetes-application-services-table
services="ctrl.application.Services"
application="ctrl.application"
public-url="ctrl.state.publicUrl"
></kubernetes-application-services-table>
<!-- table -->
<!-- table -->
<kubernetes-application-ingress-table application="ctrl.application" public-url="ctrl.state.publicUrl"></kubernetes-application-ingress-table>
<!-- table -->
</div>
<!-- !ACCESSING APPLICATION -->
<!-- AUTO SCALING -->
<div class="text-muted" style="margin-bottom: 15px"> <pr-icon icon="'move'" class="mr-1"></pr-icon>Auto-scaling </div>
<div class="small text-muted" ng-if="!ctrl.application.AutoScaler" style="margin-bottom: 15px">
<pr-icon icon="'info'" mode="'primary'" class="mr-1"></pr-icon>
This application does not have an autoscaling policy defined.
</div>
<div ng-if="ctrl.application.AutoScaler">
<div style="margin-top: 15px; width: 50%">
<table class="table">
<tbody>
<tr class="text-muted">
<td style="width: 33%">Minimum instances</td>
<td style="width: 33%">Maximum instances</td>
<td style="width: 33%">
Target CPU usage
<portainer-tooltip message="'The autoscaler will ensure enough instances are running to maintain an average CPU usage across all instances.'">
</portainer-tooltip>
</td>
</tr>
<tr>
<td data-cy="k8sAppDetail-minReplicas">{{ ctrl.application.AutoScaler.MinReplicas }}</td>
<td data-cy="k8sAppDetail-maxReplicas">{{ ctrl.application.AutoScaler.MaxReplicas }}</td>
<td data-cy="k8sAppDetail-targetCPU">{{ ctrl.application.AutoScaler.TargetCPUUtilization }}%</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- !AUTO SCALING -->
<!-- CONFIGURATIONS -->
<div class="text-muted" style="margin-bottom: 15px; margin-top: 25px">
<pr-icon icon="'file'" class="mr-1"></pr-icon>
ConfigMap or Secret
</div>
<div class="small text-muted" ng-if="!ctrl.application.Env.length > 0 && !ctrl.hasVolumeConfiguration()" style="margin-bottom: 15px">
<pr-icon icon="'info'" mode="'primary'" class="mr-1"></pr-icon>
This application is not using any environment variable, ConfigMap or Secret.
</div>
<table class="table" ng-if="ctrl.application.Env.length > 0">
<tr class="text-muted">
<td style="width: 25%">Container</td>
<td style="width: 25%">Environment variable</td>
<td style="width: 25%">Value</td>
<td style="width: 25%">ConfigMap or Secret</td>
</tr>
<tbody ng-repeat="container in ctrl.application.Containers" style="border-top: 0">
<tr ng-repeat="envvar in container.Env | orderBy: 'name'">
<td data-cy="k8sAppDetail-containerName">
{{ container.Name }}
<span ng-if="container.Type === ctrl.KubernetesPodContainerTypes.INIT">
<pr-icon icon="'asterisk'"></pr-icon>
{{ envvar.valueFrom.fieldRef.fieldPath }} (<a href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/" target="_blank">init container</a
>)</span
>
</td>
<td data-cy="k8sAppDetail-envVarName">{{ envvar.name }}</td>
<td>
<span ng-if="envvar.value" data-cy="k8sAppDetail-envVarValue">{{ envvar.value }}</span>
<span ng-if="envvar.valueFrom.configMapKeyRef" data-cy="k8sAppDetail-envVarValue"
><pr-icon icon="'key'" class="mr-1"></pr-icon>{{ envvar.valueFrom.configMapKeyRef.key }}</span
>
<span ng-if="envvar.valueFrom.secretKeyRef" data-cy="k8sAppDetail-envVarValue"
><pr-icon icon="'key'" class="mr-1"></pr-icon>{{ envvar.valueFrom.secretKeyRef.key }}</span
>
<span ng-if="envvar.valueFrom.fieldRef" data-cy="k8sAppDetail-envVarValue"
><pr-icon icon="'asterisk'"></pr-icon> {{ envvar.valueFrom.fieldRef.fieldPath }} (<a
href="https://kubernetes.io/docs/tasks/inject-data-application/downward-api-volume-expose-pod-information/#capabilities-of-the-downward-api"
target="_blank"
>downward API</a
>)</span
>
<span ng-if="!envvar.value && !envvar.valueFrom.secretKeyRef && !envvar.valueFrom.configMapKeyRef && !envvar.valueFrom.fieldRef">-</span>
</td>
<td>
<span ng-if="envvar.value || envvar.valueFrom.fieldRef || (!envvar.valueFrom.secretKeyRef && !envvar.valueFrom.configMapKeyRef)">-</span>
<span ng-if="envvar.valueFrom.configMapKeyRef" data-cy="k8sAppDetail-configName"
><a ui-sref="kubernetes.configurations.configuration({ name: envvar.valueFrom.configMapKeyRef.name, namespace: ctrl.application.ResourcePool })"
><pr-icon icon="'file'" class="mr-1"></pr-icon>{{ envvar.valueFrom.configMapKeyRef.name }}</a
></span
>
<span ng-if="envvar.valueFrom.secretKeyRef" data-cy="k8sAppDetail-configName"
><a ui-sref="kubernetes.configurations.configuration({ name: envvar.valueFrom.secretKeyRef.name, namespace: ctrl.application.ResourcePool })"
><pr-icon icon="'file'" class="mr-1"></pr-icon>{{ envvar.valueFrom.secretKeyRef.name }}</a
></span
>
</td>
</tr>
</tbody>
</table>
<table class="table" ng-if="ctrl.hasVolumeConfiguration()">
<tr class="text-muted">
<td style="width: 25%">Container</td>
<td style="width: 25%">Configuration path</td>
<td style="width: 25%">Value</td>
<td style="width: 25%">Configuration</td>
</tr>
<tbody ng-repeat="container in ctrl.application.Containers" style="border-top: 0">
<tr ng-repeat="volume in container.ConfigurationVolumes track by $index" style="border-top: 0">
<td>
{{ container.Name }}
<span ng-if="container.Type === ctrl.KubernetesPodContainerTypes.INIT"
><pr-icon icon="'asterisk'"></pr-icon> {{ envvar.valueFrom.fieldRef.fieldPath }} (<a
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
target="_blank"
>init container</a
>)</span
>
</td>
<td>
{{ volume.fileMountPath }}
</td>
<td>
<pr-icon icon="'plus'" class="mr-1" ng-if="volume.configurationKey"></pr-icon>
{{ volume.configurationKey ? volume.configurationKey : '-' }}
</td>
<td>
<a ui-sref="kubernetes.configurations.configuration({ name: volume.configurationName, namespace: ctrl.application.ResourcePool })"
><pr-icon icon="'plus'" class="mr-1"></pr-icon>{{ volume.configurationName }}</a
>
</td>
</tr>
</tbody>
</table>
<!-- !CONFIGURATIONS -->
<!-- DATA PERSISTENCE -->
<div class="text-muted" style="margin-bottom: 15px; margin-top: 25px">
<pr-icon icon="'database'" class="mr-1"></pr-icon>
Data persistence
</div>
<div class="small text-muted" ng-if="!ctrl.hasPersistedFolders()">
<pr-icon icon="'info'" mode="'primary'" class="mr-1"></pr-icon>
This application has no persisted folders.
</div>
<div ng-if="ctrl.hasPersistedFolders()">
<div class="small text-muted vertical-center" style="margin-bottom: 15px">
Data access policy:
<pr-icon icon="ctrl.application.DataAccessPolicy | kubernetesApplicationDataAccessPolicyIcon"></pr-icon>
{{ ctrl.application.DataAccessPolicy | kubernetesApplicationDataAccessPolicyText }}
<portainer-tooltip position="'right'" message="ctrl.application.DataAccessPolicy | kubernetesApplicationDataAccessPolicyTooltip"> </portainer-tooltip>
</div>
<table class="table" ng-if="ctrl.application.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.SHARED">
<tr class="text-muted">
<td style="width: 33%">Persisted folder</td>
<td style="width: 66%">Persistence</td>
</tr>
<tbody ng-repeat="container in ctrl.application.Containers" style="border-top: 0">
<tr ng-repeat="volume in container.PersistedFolders track by $index">
<td data-cy="k8sAppDetail-volMountPath">
{{ volume.MountPath }}
</td>
<td ng-if="volume.PersistentVolumeClaimName">
<a
class="hyperlink"
ui-sref="kubernetes.volumes.volume({ name: volume.PersistentVolumeClaimName, namespace: ctrl.application.ResourcePool })"
data-cy="k8sAppDetail-volClaimName"
><pr-icon icon="'database'" class="mr-1"></pr-icon>{{ volume.PersistentVolumeClaimName }}</a
>
</td>
<td ng-if="volume.HostPath"> {{ volume.HostPath }} on host filesystem </td>
</tr>
</tbody>
</table>
<table class="table" ng-if="ctrl.application.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.ISOLATED">
<thead>
<tr class="text-muted">
<td style="width: 25%">Container name</td>
<td style="width: 25%">Pod name</td>
<td style="width: 25%">Persisted folder</td>
<td style="width: 25%">Persistence</td>
</tr>
</thead>
<tbody ng-repeat="container in ctrl.allContainers track by $index" style="border-top: none">
<tr ng-repeat="volume in container.PersistedFolders track by $index">
<td>
{{ container.Name }}
<span ng-if="container.Type === ctrl.KubernetesPodContainerTypes.INIT"
><pr-icon icon="'asterisk'"></pr-icon> {{ envvar.valueFrom.fieldRef.fieldPath }} (<a
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
target="_blank"
>init container</a
>)</span
>
</td>
<td>{{ container.PodName }}</td>
<td>
{{ volume.MountPath }}
</td>
<td ng-if="volume.PersistentVolumeClaimName">
<a
class="hyperlink"
ui-sref="kubernetes.volumes.volume({ name: volume.PersistentVolumeClaimName + '-' + container.PodName, namespace: ctrl.application.ResourcePool })"
>
<pr-icon icon="'database'" class="mr-1"></pr-icon>{{ volume.PersistentVolumeClaimName + '-' + container.PodName }}</a
>
</td>
<td ng-if="volume.HostPath"> {{ volume.HostPath }} on host filesystem </td>
</tr>
</tbody>
</table>
<!-- !DATA PERSISTENCE -->
</div>
</rd-widget-body>
</rd-widget>
<application-details-widget></application-details-widget>
</div>
</div>

@ -15,9 +15,6 @@ import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
import { KubernetesPodContainerTypes } from 'Kubernetes/pod/models/index';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { confirmUpdate, confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
function computeTolerations(nodes, application) {
const pod = application.Pods[0];
@ -146,11 +143,6 @@ class KubernetesApplicationController {
this.getApplicationAsync = this.getApplicationAsync.bind(this);
this.getEvents = this.getEvents.bind(this);
this.getEventsAsync = this.getEventsAsync.bind(this);
this.updateApplicationKindText = this.updateApplicationKindText.bind(this);
this.updateApplicationAsync = this.updateApplicationAsync.bind(this);
this.redeployApplicationAsync = this.redeployApplicationAsync.bind(this);
this.rollbackApplicationAsync = this.rollbackApplicationAsync.bind(this);
this.copyLoadBalancerIP = this.copyLoadBalancerIP.bind(this);
}
selectTab(index) {
@ -166,128 +158,10 @@ class KubernetesApplicationController {
return KubernetesNamespaceHelper.isSystemNamespace(this.application.ResourcePool);
}
isExternalApplication() {
return KubernetesApplicationHelper.isExternalApplication(this.application);
}
copyLoadBalancerIP() {
this.clipboard.copyText(this.application.LoadBalancerIPAddress);
$('#copyNotificationLB').show().fadeOut(2500);
}
copyApplicationName() {
this.clipboard.copyText(this.application.Name);
$('#copyNotificationApplicationName').show().fadeOut(2500);
}
hasPersistedFolders() {
return this.application && this.application.PersistedFolders.length;
}
hasVolumeConfiguration() {
return this.application && this.application.ConfigurationVolumes.length;
}
hasEventWarnings() {
return this.state.eventWarningCount;
}
buildIngressRuleURL(rule) {
const hostname = rule.Host ? rule.Host : rule.IP;
return 'http://' + hostname + rule.Path;
}
portHasIngressRules(port) {
return port.IngressRules.length > 0;
}
ruleCanBeDisplayed(rule) {
return !rule.Host && !rule.IP ? false : true;
}
isStack() {
return this.application.StackId;
}
/**
* ROLLBACK
*/
async rollbackApplicationAsync() {
try {
// await this.KubernetesApplicationService.rollback(this.application, this.formValues.SelectedRevision);
const revision = _.nth(this.application.Revisions, -2);
await this.KubernetesApplicationService.rollback(this.application, revision);
this.Notifications.success('Success', 'Application successfully rolled back');
this.$state.reload(this.$state.current);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to rollback the application');
}
}
rollbackApplication() {
confirmUpdate('Rolling back the application to a previous configuration may cause service interruption. Do you wish to continue?', (confirmed) => {
if (confirmed) {
return this.$async(this.rollbackApplicationAsync);
}
});
}
/**
* REDEPLOY
*/
async redeployApplicationAsync() {
const confirmed = await confirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message: 'Redeploying the application may cause a service interruption. Do you wish to continue?',
confirmButton: buildConfirmButton('Redeploy'),
});
if (!confirmed) {
return;
}
try {
const promises = _.map(this.application.Pods, (item) => this.KubernetesPodService.delete(item));
await Promise.all(promises);
this.Notifications.success('Success', 'Application successfully redeployed');
this.$state.reload(this.$state.current);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to redeploy the application');
}
}
redeployApplication() {
return this.$async(this.redeployApplicationAsync);
}
/**
* UPDATE
*/
async updateApplicationAsync() {
try {
const application = angular.copy(this.application);
application.Note = this.formValues.Note;
await this.KubernetesApplicationService.patch(this.application, application, true);
this.Notifications.success('Success', 'Application successfully updated');
this.$state.reload(this.$state.current);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to update application');
}
}
updateApplication() {
return this.$async(this.updateApplicationAsync);
}
updateApplicationKindText() {
if (this.application.ApplicationKind === this.KubernetesDeploymentTypes.GIT) {
this.state.appType = `git repository`;
} else if (this.application.ApplicationKind === this.KubernetesDeploymentTypes.CONTENT) {
this.state.appType = `manifest`;
} else if (this.application.ApplicationKind === this.KubernetesDeploymentTypes.URL) {
this.state.appType = `manifest`;
}
}
/**
* EVENTS
*/
@ -325,22 +199,7 @@ class KubernetesApplicationController {
this.KubernetesNodeService.get(),
]);
this.application = application;
if (this.application.StackId) {
this.stack = await this.StackService.stack(application.StackId);
}
this.allContainers = KubernetesApplicationHelper.associateAllContainersAndApplication(application);
this.formValues.Note = this.application.Note;
this.formValues.Services = this.application.Services;
if (this.application.Note) {
this.state.expandedNote = true;
}
if (this.application.CurrentRevision) {
this.formValues.SelectedRevision = _.find(this.application.Revisions, { revision: this.application.CurrentRevision.revision });
}
this.state.useIngress = _.find(application.PublishedPorts, (p) => {
return this.portHasIngressRules(p);
});
this.placements = computePlacements(nodes, this.application);
this.state.placementWarning = _.find(this.placements, { AcceptsApplication: true }) ? false : true;
@ -379,7 +238,6 @@ class KubernetesApplicationController {
eventWarningCount: 0,
placementWarning: false,
expandedNote: false,
useIngress: false,
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
publicUrl: this.endpoint.PublicURL,
};
@ -391,12 +249,8 @@ class KubernetesApplicationController {
SelectedRevision: undefined,
};
const resourcePools = await this.KubernetesResourcePoolService.get();
this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name);
await this.getApplication();
await this.getEvents();
this.updateApplicationKindText();
this.state.viewReady = true;
}

@ -1,30 +0,0 @@
import _ from 'lodash-es';
export default class KubernetesApplicationIngressController {
/* @ngInject */
constructor($async, KubernetesIngressService) {
this.$async = $async;
this.KubernetesIngressService = KubernetesIngressService;
}
$onInit() {
return this.$async(async () => {
this.hasIngress;
this.applicationIngress = [];
const ingresses = await this.KubernetesIngressService.get(this.application.ResourcePool);
const services = this.application.Services;
_.forEach(services, (service) => {
_.forEach(ingresses, (ingress) => {
_.forEach(ingress.Paths, (path) => {
if (path.ServiceName === service.metadata.name) {
path.Secure = ingress.TLS && ingress.TLS.filter((tls) => tls.hosts && tls.hosts.includes(path.Host)).length > 0;
this.applicationIngress.push(path);
this.hasIngress = true;
}
});
});
});
});
}
}

@ -1,28 +0,0 @@
<div style="margin-top: 15px" ng-if="$ctrl.hasIngress">
<table class="table">
<tbody>
<tr class="text-muted">
<td style="width: 15%">Ingress name</td>
<td style="width: 10%">Service name</td>
<td style="width: 10%">Host</td>
<td style="width: 10%">Port</td>
<td style="width: 10%">Path</td>
<td style="width: 15%">HTTP Route</td>
</tr>
<tr ng-repeat="ingress in $ctrl.applicationIngress">
<td
><a authorization="K8sIngressesW" ui-sref="kubernetes.ingresses.edit({ name: ingress.IngressName, namespace: $ctrl.application.ResourcePool })">{{
ingress.IngressName
}}</a></td
>
<td>{{ ingress.ServiceName }}</td>
<td>{{ ingress.Host }}</td>
<td>{{ ingress.Port }}</td>
<td>{{ ingress.Path }}</td>
<td
><a target="_blank" href="{{ ingress.Secure ? 'https' : 'http' }}://{{ ingress.Host }}{{ ingress.Path }}">{{ ingress.Host }}{{ ingress.Path }}</a></td
>
</tr>
</tbody>
</table>
</div>

@ -1,11 +0,0 @@
import angular from 'angular';
import controller from './ingress-table.controller';
angular.module('portainer.kubernetes').component('kubernetesApplicationIngressTable', {
templateUrl: './ingress-table.html',
controller,
bindings: {
application: '<',
publicUrl: '<',
},
});

@ -1,62 +0,0 @@
<!-- table -->
<div style="margin-top: 15px">
<table class="table">
<tbody>
<tr class="text-muted">
<td style="width: 15%">Service name</td>
<td style="width: 10%">Type</td>
<td style="width: 10%">Cluster IP</td>
<td style="width: 10%">External IP</td>
<td style="width: 10%">Container port</td>
<td style="width: 15%">Service port(s)</td>
</tr>
<tr ng-repeat="service in $ctrl.services">
<td>{{ service.metadata.name }}</td>
<td>{{ service.spec.type }}</td>
<td>{{ service.spec.clusterIP }}</td>
<td ng-show="service.spec.type === 'LoadBalancer'">
<div ng-show="service.status.loadBalancer.ingress">
<a class="vertical-center hyperlink" target="_blank" ng-href="http://{{ service.status.loadBalancer.ingress[0].ip }}:{{ service.spec.ports[0].port }}">
<pr-icon icon="'external-link'"></pr-icon>
<span data-cy="k8sAppDetail-containerPort"> Access </span>
</a>
</div>
<div ng-show="!service.status.loadBalancer.ingress">
{{ service.spec.externalIP ? service.spec.externalIP : 'pending...' }}
</div>
</td>
<td ng-show="service.spec.type !== 'LoadBalancer'">{{ service.spec.externalIP ? service.spec.externalIP : '-' }}</td>
<td data-cy="k8sAppDetail-containerPort">
<div ng-repeat="port in service.spec.ports">{{ port.targetPort }}</div>
</td>
<td ng-if="!ctrl.portHasIngressRules(port)">
<div ng-repeat="port in service.spec.ports">
<a
class="vertical-center hyperlink"
ng-if="$ctrl.publicUrl && port.nodePort"
ng-href="http://{{ $ctrl.publicUrl }}:{{ port.nodePort }}"
target="_blank"
style="margin-left: 5px"
>
<pr-icon icon="'external-link'"></pr-icon>
<span data-cy="k8sAppDetail-containerPort">
{{ port.port }}
</span>
<span>{{ port.nodePort ? ':' : '' }}</span>
<span data-cy="k8sAppDetail-nodePort"> {{ port.nodePort }}/{{ port.protocol }} </span>
</a>
<div ng-if="!$ctrl.publicUrl">
<span data-cy="k8sAppDetail-servicePort">
{{ port.port }}
</span>
<span>{{ port.nodePort ? ':' : '' }}</span>
<span data-cy="k8sAppDetail-nodePort"> {{ port.nodePort }}/{{ port.protocol }} </span>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>

@ -1,10 +0,0 @@
import angular from 'angular';
angular.module('portainer.kubernetes').component('kubernetesApplicationServicesTable', {
templateUrl: './services-table.html',
bindings: {
services: '<',
application: '<',
publicUrl: '<',
},
});

@ -1,6 +1,6 @@
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { confirmStackUpdate } from '@/react/docker/stacks/common/confirm-stack-update';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';

@ -2,9 +2,9 @@ import { ResourceControlType } from '@/react/portainer/access-control/types';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { StackStatus, StackType } from '@/react/docker/stacks/types';
import { StackStatus, StackType } from '@/react/common/stacks/types';
import { extractContainerNames } from '@/portainer/helpers/stackHelper';
import { confirmStackUpdate } from '@/react/docker/stacks/common/confirm-stack-update';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import { confirm, confirmDelete, confirmWebEditorDiscard } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';

@ -17,8 +17,8 @@ import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { useTableState } from '@@/datatables/useTableState';
import { useContainers } from '../../containers/queries/containers';
import { RowProvider } from '../../containers/ListView/ContainersDatatable/RowContext';
import { useContainers } from '../../../docker/containers/queries/containers';
import { RowProvider } from '../../../docker/containers/ListView/ContainersDatatable/RowContext';
const storageKey = 'stack-containers';
const settingsStore = createStore(storageKey);

@ -0,0 +1 @@
Stacks are placed in the `/app/react/common` folder, because they are used by both Kubernetes and Docker environments and are saved locally to the Portainer database.

@ -0,0 +1,25 @@
import { useQuery } from 'react-query';
import axios from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { StackFile, StackId } from './types';
const queryKeys = {
stackFile: (stackId?: StackId) => ['stacks', stackId, 'file'],
};
export function useStackFile(stackId?: StackId) {
return useQuery(queryKeys.stackFile(stackId), () => getStackFile(stackId), {
...withError('Unable to retrieve stack'),
enabled: !!stackId,
});
}
async function getStackFile(stackId?: StackId) {
if (!stackId) {
return Promise.resolve(undefined);
}
const { data } = await axios.get<StackFile>(`/stacks/${stackId}/file`);
return data;
}

@ -23,3 +23,7 @@ export enum StackStatus {
Active = 1,
Inactive,
}
export type StackFile = {
StackFileContent: string;
};

@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { compact } from 'lodash';
import { ServiceList } from 'kubernetes-types/core/v1';
import { withError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
@ -11,10 +12,11 @@ import { getNamespaces } from '../namespaces/service';
import { Service } from './types';
export const queryKeys = {
list: (environmentId: EnvironmentId) =>
clusterServices: (environmentId: EnvironmentId) =>
['environments', environmentId, 'kubernetes', 'services'] as const,
};
// get a list of services for a specific namespace from the Portainer API
async function getServices(
environmentId: EnvironmentId,
namespace: string,
@ -37,7 +39,7 @@ async function getServices(
export function useServices(environmentId: EnvironmentId) {
return useQuery(
queryKeys.list(environmentId),
queryKeys.clusterServices(environmentId),
async () => {
const namespaces = await getNamespaces(environmentId);
const settledServicesPromise = await Promise.allSettled(
@ -53,12 +55,26 @@ export function useServices(environmentId: EnvironmentId) {
);
}
// getNamespaceServices is used to get a list of services for a specific namespace directly from the Kubernetes API
export async function getNamespaceServices(
environmentId: EnvironmentId,
namespace: string,
queryParams?: Record<string, string>
) {
const { data: services } = await axios.get<ServiceList>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services`,
{
params: queryParams,
}
);
return services.items;
}
export function useMutationDeleteServices(environmentId: EnvironmentId) {
const queryClient = useQueryClient();
return useMutation(deleteServices, {
onSuccess: () =>
// use the exact same query keys as the useServices hook to invalidate the services list
queryClient.invalidateQueries(queryKeys.list(environmentId)),
queryClient.invalidateQueries(queryKeys.clusterServices(environmentId)),
...withError('Unable to delete service(s)'),
});
}

@ -0,0 +1,74 @@
import { Move } from 'lucide-react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Icon } from '@@/Icon';
import { TextTip } from '@@/Tip/TextTip';
import { Tooltip } from '@@/Tip/Tooltip';
import { Application } from '../../types';
import { useApplicationHorizontalPodAutoscalers } from '../../application.queries';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appName: string;
app?: Application;
};
export function ApplicationAutoScalingTable({
environmentId,
namespace,
appName,
app,
}: Props) {
const { data: appAutoScalar } = useApplicationHorizontalPodAutoscalers(
environmentId,
namespace,
appName,
app
);
return (
<>
<div className="text-muted mb-4 flex items-center">
<Icon icon={Move} className="!mr-2" />
Auto-scaling
</div>
{!appAutoScalar && (
<TextTip color="blue">
This application does not have an autoscaling policy defined.
</TextTip>
)}
{appAutoScalar && (
<div className="mt-4 w-3/5">
<table className="table">
<tbody>
<tr className="text-muted">
<td className="w-1/3">Minimum instances</td>
<td className="w-1/3">Maximum instances</td>
<td className="w-1/3">
<div className="flex min-w-max items-center gap-1">
Target CPU usage
<Tooltip message="The autoscaler will ensure enough instances are running to maintain an average CPU usage across all instances." />
</div>
</td>
</tr>
<tr>
<td data-cy="k8sAppDetail-minReplicas">
{appAutoScalar.spec?.minReplicas}
</td>
<td data-cy="k8sAppDetail-maxReplicas">
{appAutoScalar.spec?.maxReplicas}
</td>
<td data-cy="k8sAppDetail-targetCPU">
{appAutoScalar.spec?.targetCPUUtilizationPercentage}%
</td>
</tr>
</tbody>
</table>
</div>
)}
</>
);
}

@ -0,0 +1,142 @@
import { Pencil, Plus } from 'lucide-react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { Pod } from 'kubernetes-types/core/v1';
import { Authorized } from '@/react/hooks/useUser';
import { useStackFile } from '@/react/common/stacks/stack.service';
import { Widget, WidgetBody } from '@@/Widget';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { Icon } from '@@/Icon';
import {
useApplication,
useApplicationServices,
} from '../../application.queries';
import { isSystemNamespace } from '../../../namespaces/utils';
import { applicationIsKind, isExternalApplication } from '../../utils';
import { appStackIdLabel } from '../../constants';
import { RestartApplicationButton } from './RestartApplicationButton';
import { RedeployApplicationButton } from './RedeployApplicationButton';
import { RollbackApplicationButton } from './RollbackApplicationButton';
import { ApplicationServicesTable } from './ApplicationServicesTable';
import { ApplicationIngressesTable } from './ApplicationIngressesTable';
import { ApplicationAutoScalingTable } from './ApplicationAutoScalingTable';
import { ApplicationEnvVarsTable } from './ApplicationEnvVarsTable';
import { ApplicationVolumeConfigsTable } from './ApplicationVolumeConfigsTable';
import { ApplicationPersistentDataTable } from './ApplicationPersistentDataTable';
export function ApplicationDetailsWidget() {
const stateAndParams = useCurrentStateAndParams();
const {
params: {
namespace,
name,
'resource-type': resourceType,
endpointId: environmentId,
},
} = stateAndParams;
// get app info
const appQuery = useApplication(environmentId, namespace, name, resourceType);
const app = appQuery.data;
const externalApp = app && isExternalApplication(app);
const appStackId = Number(app?.metadata?.labels?.[appStackIdLabel]);
const appStackFileQuery = useStackFile(appStackId);
const { data: appServices } = useApplicationServices(
environmentId,
namespace,
name,
app
);
return (
<Widget>
<WidgetBody>
{!isSystemNamespace(namespace) && (
<div className="mb-4 flex flex-wrap gap-2">
<Authorized authorizations="K8sApplicationDetailsW">
<Link to="kubernetes.applications.application.edit">
<Button
type="button"
color="light"
size="small"
className="hover:decoration-none !ml-0"
data-cy="k8sAppDetail-editAppButton"
>
<Icon icon={Pencil} className="mr-1" />
{externalApp
? 'Edit external application'
: 'Edit this application'}
</Button>
</Link>
</Authorized>
{!applicationIsKind<Pod>('Pod', app) && (
<>
<RestartApplicationButton />
<RedeployApplicationButton
environmentId={environmentId}
namespace={namespace}
appName={name}
app={app}
/>
</>
)}
{!externalApp && (
<RollbackApplicationButton
environmentId={environmentId}
namespace={namespace}
appName={name}
app={app}
/>
)}
{appStackFileQuery.data && (
<Link
to="kubernetes.templates.custom.new"
params={{
fileContent: appStackFileQuery.data.StackFileContent,
}}
>
<Button
type="button"
color="primary"
size="small"
className="hover:decoration-none !ml-0"
data-cy="k8sAppDetail-createCustomTemplateButton"
>
<Icon icon={Plus} className="mr-1" />
Create template from application
</Button>
</Link>
)}
</div>
)}
<ApplicationServicesTable
environmentId={environmentId}
appServices={appServices}
/>
<ApplicationIngressesTable
appServices={appServices}
environmentId={environmentId}
namespace={namespace}
/>
<ApplicationAutoScalingTable
environmentId={environmentId}
namespace={namespace}
appName={name}
app={app}
/>
<ApplicationEnvVarsTable namespace={namespace} app={app} />
<ApplicationVolumeConfigsTable namespace={namespace} app={app} />
<ApplicationPersistentDataTable
environmentId={environmentId}
namespace={namespace}
appName={name}
app={app}
/>
</WidgetBody>
</Widget>
);
}

@ -0,0 +1,172 @@
import { EnvVar, Pod } from 'kubernetes-types/core/v1';
import { Asterisk, File, Key } from 'lucide-react';
import { Icon } from '@@/Icon';
import { TextTip } from '@@/Tip/TextTip';
import { Link } from '@@/Link';
import { Application } from '../../types';
import { applicationIsKind } from '../../utils';
type Props = {
namespace: string;
app?: Application;
};
export function ApplicationEnvVarsTable({ namespace, app }: Props) {
const appEnvVars = getApplicationEnvironmentVariables(app);
return (
<>
<div className="text-muted mb-4 mt-6 flex items-center">
<Icon icon={File} className="!mr-2" />
Configuration
</div>
{appEnvVars.length === 0 && (
<TextTip color="blue">
This application is not using any environment variable or
configuration.
</TextTip>
)}
{appEnvVars.length > 0 && (
<table className="table">
<tbody>
<tr className="text-muted">
<td className="w-1/4">Container</td>
<td className="w-1/4">Environment variable</td>
<td className="w-1/4">Value</td>
<td className="w-1/4">Configuration</td>
</tr>
{appEnvVars.map((envVar, index) => (
<tr key={index}>
<td data-cy="k8sAppDetail-containerName">
{envVar.containerName}
{envVar.isInitContainer && (
<span>
<Icon icon={Asterisk} className="!ml-1" />
{envVar.valueFrom?.fieldRef?.fieldPath} (
<a
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
target="_blank"
rel="noopener noreferrer"
>
init container
</a>
)
</span>
)}
</td>
<td data-cy="k8sAppDetail-envVarName">{envVar.name}</td>
<td data-cy="k8sAppDetail-envVarValue">
{envVar.value && <span>{envVar.value}</span>}
{envVar.valueFrom?.fieldRef?.fieldPath && (
<span>
<Icon icon={Asterisk} className="!ml-1" />
{envVar.valueFrom.fieldRef.fieldPath} (
<a
href="https://kubernetes.io/docs/tasks/inject-data-application/downward-api-volume-expose-pod-information/"
target="_blank"
rel="noopener noreferrer"
>
downward API
</a>
)
</span>
)}
{envVar.valueFrom?.secretKeyRef?.key && (
<span className="flex items-center">
<Icon icon={Key} className="!mr-1" />
{envVar.valueFrom.secretKeyRef.key}
</span>
)}
{envVar.valueFrom?.configMapKeyRef?.key && (
<span className="flex items-center">
<Icon icon={Key} className="!mr-1" />
{envVar.valueFrom.configMapKeyRef.key}
</span>
)}
{!envVar.value && !envVar.valueFrom && <span>-</span>}
</td>
<td data-cy="k8sAppDetail-configName">
{!envVar.valueFrom?.configMapKeyRef?.name &&
!envVar.valueFrom?.secretKeyRef?.name && <span>-</span>}
{envVar.valueFrom?.configMapKeyRef && (
<span>
<Link
to="kubernetes.configurations.configuration"
params={{
name: envVar.valueFrom.configMapKeyRef.name,
namespace,
}}
className="flex items-center"
>
<Icon icon={File} className="!mr-1" />
{envVar.valueFrom.configMapKeyRef.name}
</Link>
</span>
)}
{envVar.valueFrom?.secretKeyRef && (
<span>
<Link
to="kubernetes.configurations.configuration"
params={{
name: envVar.valueFrom.secretKeyRef.name,
namespace,
}}
className="flex items-center"
>
<Icon icon={File} className="!mr-1" />
{envVar.valueFrom.secretKeyRef.name}
</Link>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
);
}
interface ContainerEnvVar extends EnvVar {
containerName: string;
isInitContainer: boolean;
}
function getApplicationEnvironmentVariables(
app?: Application
): ContainerEnvVar[] {
if (!app) {
return [];
}
const podSpec = applicationIsKind<Pod>('Pod', app)
? app.spec
: app.spec?.template?.spec;
const appContainers = podSpec?.containers || [];
const appInitContainers = podSpec?.initContainers || [];
// get all the environment variables for each container
const appContainersEnvVars =
appContainers?.flatMap(
(container) =>
container?.env?.map((envVar) => ({
...envVar,
containerName: container.name,
isInitContainer: false,
})) || []
) || [];
const appInitContainersEnvVars =
appInitContainers?.flatMap(
(container) =>
container?.env?.map((envVar) => ({
...envVar,
containerName: container.name,
isInitContainer: true,
})) || []
) || [];
return [...appContainersEnvVars, ...appInitContainersEnvVars];
}

@ -0,0 +1,125 @@
import { Service } from 'kubernetes-types/core/v1';
import { useMemo } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useIngresses } from '@/react/kubernetes/ingresses/queries';
import { Ingress } from '@/react/kubernetes/ingresses/types';
import { Authorized } from '@/react/hooks/useUser';
import { Link } from '@@/Link';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appServices?: Service[];
};
export function ApplicationIngressesTable({
environmentId,
namespace,
appServices,
}: Props) {
const namespaceIngresses = useIngresses(environmentId, [namespace]);
// getIngressPathsForAppServices could be expensive, so memoize it
const ingressPathsForAppServices = useMemo(
() => getIngressPathsForAppServices(namespaceIngresses.data, appServices),
[namespaceIngresses.data, appServices]
);
if (!ingressPathsForAppServices.length) {
return null;
}
return (
<table className="mt-4 table">
<tbody>
<tr className="text-muted">
<td className="w-[15%]">Ingress name</td>
<td className="w-[10%]">Service name</td>
<td className="w-[10%]">Host</td>
<td className="w-[10%]">Port</td>
<td className="w-[10%]">Path</td>
<td className="w-[15%]">HTTP Route</td>
</tr>
{ingressPathsForAppServices.map((ingressPath, index) => (
<tr key={index}>
<td>
<Authorized authorizations="K8sIngressesW">
<Link
to="kubernetes.ingresses.edit"
params={{ name: ingressPath.ingressName, namespace }}
>
{ingressPath.ingressName}
</Link>
</Authorized>
</td>
<td>{ingressPath.serviceName}</td>
<td>{ingressPath.host}</td>
<td>{ingressPath.port}</td>
<td>{ingressPath.path}</td>
<td>
<a
target="_blank"
rel="noopener noreferrer"
href={`${ingressPath.secure ? 'https' : 'http'}://${
ingressPath.host
}${ingressPath.path}`}
>
{ingressPath.host}
{ingressPath.path}
</a>
</td>
</tr>
))}
</tbody>
</table>
);
}
type IngressPath = {
ingressName: string;
serviceName: string;
port: number;
secure: boolean;
host: string;
path: string;
};
function getIngressPathsForAppServices(
ingresses?: Ingress[],
services?: Service[]
): IngressPath[] {
if (!ingresses || !services) {
return [];
}
const matchingIngressesPaths = ingresses.flatMap((ingress) => {
// for each ingress get an array of ingress paths that match the app services
const matchingIngressPaths = ingress.Paths.filter((path) =>
services?.some((service) => {
const servicePorts = service.spec?.ports?.map((port) => port.port);
// include the ingress if the ingress path has a matching service name and port
return (
path.ServiceName === service.metadata?.name &&
servicePorts?.includes(path.Port)
);
})
).map((path) => {
const secure =
(ingress.TLS &&
ingress.TLS.filter(
(tls) => tls.Hosts && tls.Hosts.includes(path.Host)
).length > 0) ??
false;
return {
ingressName: ingress.Name,
serviceName: path.ServiceName,
port: path.Port,
secure,
host: path.Host,
path: path.Path,
};
});
return matchingIngressPaths;
});
return matchingIngressesPaths;
}

@ -0,0 +1,268 @@
import { useMemo } from 'react';
import { Asterisk, Box, Boxes, Database } from 'lucide-react';
import { Container, Pod, Volume } from 'kubernetes-types/core/v1';
import { StatefulSet } from 'kubernetes-types/apps/v1';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Icon } from '@@/Icon';
import { TextTip } from '@@/Tip/TextTip';
import { Tooltip } from '@@/Tip/Tooltip';
import { Link } from '@@/Link';
import { Application } from '../../types';
import { applicationIsKind } from '../../utils';
import { useApplicationPods } from '../../application.queries';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appName: string;
app?: Application;
};
export function ApplicationPersistentDataTable({
namespace,
app,
environmentId,
appName,
}: Props) {
const { data: pods } = useApplicationPods(
environmentId,
namespace,
appName,
app
);
const persistedFolders = useMemo(
() => getPersistedFolders(app, pods),
[app, pods]
);
const dataAccessPolicy = getDataAccessPolicy(app);
return (
<>
<div className="text-muted mb-4 mt-6 flex items-center">
<Icon icon={Database} className="!mr-2 !shrink-0" />
Data persistence
</div>
{!persistedFolders.length && (
<TextTip color="blue">
This application has no persisted folders.
</TextTip>
)}
{persistedFolders.length > 0 && (
<>
<div className="small text-muted vertical-center mb-4">
Data access policy:
{dataAccessPolicy === 'isolated' && (
<>
<Icon icon={Boxes} />
Isolated
<Tooltip message="All the instances of this application are using their own data." />
</>
)}
{dataAccessPolicy === 'shared' && (
<>
<Icon icon={Box} />
Shared
<Tooltip message="All the instances of this application are sharing the same data." />
</>
)}
</div>
{dataAccessPolicy === 'isolated' && (
<table className="table">
<thead>
<tr className="text-muted">
<td className="w-1/4">Container name</td>
<td className="w-1/4">Pod name</td>
<td className="w-1/4">Persisted folder</td>
<td className="w-1/4">Persistence</td>
</tr>
</thead>
<tbody>
{persistedFolders.map((persistedFolder, index) => (
<tr key={index}>
<td>
{persistedFolder.volumeMount.container.name}
{persistedFolder.isContainerInit && (
<span>
<Icon icon={Asterisk} className="!mr-1" />(
<a
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
target="_blank"
rel="noopener noreferrer"
>
init container
</a>
)
</span>
)}
</td>
<td>{persistedFolder.volumeMount?.pod?.metadata?.name}</td>
<td>{persistedFolder.volumeMount.mountPath}</td>
<td>
{persistedFolder.volume.persistentVolumeClaim && (
<Link
className="hyperlink flex items-center"
to="kubernetes.volumes.volume"
params={{
name: `${persistedFolder.volume.persistentVolumeClaim.claimName}-${persistedFolder.volumeMount?.pod?.metadata?.name}`,
namespace,
}}
>
<Icon icon={Database} className="!mr-1 shrink-0" />
{`${persistedFolder.volume.persistentVolumeClaim.claimName}-${persistedFolder.volumeMount?.pod?.metadata?.name}`}
</Link>
)}
{persistedFolder.volume.hostPath &&
`${persistedFolder.volume.hostPath.path} on host filesystem`}
</td>
</tr>
))}
</tbody>
</table>
)}
{dataAccessPolicy === 'shared' && (
<table className="table">
<thead>
<tr className="text-muted">
<td className="w-1/3">Persisted folder</td>
<td className="w-2/3">Persistence</td>
</tr>
</thead>
<tbody className="border-t-0">
{persistedFolders.map((persistedFolder, index) => (
<tr key={index}>
<td data-cy="k8sAppDetail-volMountPath">
{persistedFolder.volumeMount.mountPath}
</td>
<td>
{persistedFolder.volume.persistentVolumeClaim && (
<Link
className="hyperlink flex items-center"
to="kubernetes.volumes.volume"
params={{
name: persistedFolder.volume.persistentVolumeClaim
.claimName,
namespace,
}}
>
<Icon icon={Database} className="!mr-1 shrink-0" />
{
persistedFolder.volume.persistentVolumeClaim
.claimName
}
</Link>
)}
{persistedFolder.volume.hostPath &&
`${persistedFolder.volume.hostPath.path} on host filesystem`}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</>
);
}
function getDataAccessPolicy(app?: Application) {
if (!app || applicationIsKind<Pod>('Pod', app)) {
return 'none';
}
if (applicationIsKind<StatefulSet>('StatefulSet', app)) {
return 'isolated';
}
return 'shared';
}
function getPodsMatchingContainer(pods: Pod[], container: Container) {
const matchingPods = pods.filter((pod) => {
const podContainers = pod.spec?.containers || [];
const podInitContainers = pod.spec?.initContainers || [];
const podAllContainers = [...podContainers, ...podInitContainers];
return podAllContainers.some(
(podContainer) =>
podContainer.name === container.name &&
podContainer.image === container.image
);
});
return matchingPods;
}
function getPersistedFolders(app?: Application, pods?: Pod[]) {
if (!app || !pods) {
return [];
}
const podSpec = applicationIsKind<Pod>('Pod', app)
? app.spec
: app.spec?.template?.spec;
const appVolumes = podSpec?.volumes || [];
const appVolumeClaimVolumes = getVolumeClaimTemplates(app, appVolumes);
const appAllVolumes = [...appVolumes, ...appVolumeClaimVolumes];
const appContainers = podSpec?.containers || [];
const appInitContainers = podSpec?.initContainers || [];
const appAllContainers = [...appContainers, ...appInitContainers];
// for each volume, find the volumeMounts that match it
const persistedFolders = appAllVolumes.flatMap((volume) => {
if (volume.persistentVolumeClaim || volume.hostPath) {
const volumeMounts = appAllContainers.flatMap((container) => {
const matchingPods = getPodsMatchingContainer(pods, container);
return (
container.volumeMounts?.flatMap(
(containerVolumeMount) =>
matchingPods.map((pod) => ({
...containerVolumeMount,
container,
pod,
})) || []
) || []
);
});
const uniqueMatchingVolumeMounts = volumeMounts.filter(
(volumeMount, index, self) =>
self.indexOf(volumeMount) === index && // remove volumeMounts with duplicate names
volumeMount.name === volume.name // remove volumeMounts that don't match the volume
);
return uniqueMatchingVolumeMounts.map((volumeMount) => ({
volume,
volumeMount,
isContainerInit: appInitContainers.some(
(container) => container.name === volumeMount.container.name
),
}));
}
return [];
});
return persistedFolders;
}
function getVolumeClaimTemplates(app: Application, volumes: Volume[]) {
if (
applicationIsKind<StatefulSet>('StatefulSet', app) &&
app.spec?.volumeClaimTemplates
) {
const volumeClaimTemplates: Volume[] = app.spec.volumeClaimTemplates.map(
(vc) => ({
name: vc.metadata?.name || '',
persistentVolumeClaim: { claimName: vc.metadata?.name || '' },
})
);
const newPVC = volumeClaimTemplates.filter(
(vc) =>
!volumes.find(
(v) =>
v.persistentVolumeClaim?.claimName ===
vc.persistentVolumeClaim?.claimName
)
);
return newPVC;
}
return [];
}

@ -0,0 +1,130 @@
import { Service } from 'kubernetes-types/core/v1';
import { ExternalLink } from 'lucide-react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { Icon } from '@@/Icon';
import { TextTip } from '@@/Tip/TextTip';
type Props = {
environmentId: EnvironmentId;
appServices?: Service[];
};
export function ApplicationServicesTable({
environmentId,
appServices,
}: Props) {
const { data: environment } = useEnvironment(environmentId);
return (
<>
<div className="text-muted mb-4 flex items-center">
<Icon icon={ExternalLink} className="!mr-2" />
Accessing the application
</div>
{appServices && appServices.length === 0 && (
<TextTip color="blue" className="mb-4">
This application is not exposing any port.
</TextTip>
)}
{appServices && appServices.length > 0 && (
<>
<TextTip color="blue" className="mb-4">
This application is exposed through service(s) as below:
</TextTip>
<table className="table">
<tbody>
<tr className="text-muted">
<td className="w-[15%]">Service name</td>
<td className="w-[10%]">Type</td>
<td className="w-[10%]">Cluster IP</td>
<td className="w-[10%]">External IP</td>
<td className="w-[10%]">Container port</td>
<td className="w-[15%]">Service port(s)</td>
</tr>
{appServices.map((service) => (
<tr key={service.metadata?.name}>
<td>{service.metadata?.name}</td>
<td>{service.spec?.type}</td>
<td>{service.spec?.clusterIP}</td>
{service.spec?.type === 'LoadBalancer' && (
<td>
{service.status?.loadBalancer?.ingress?.[0] &&
service.spec?.ports?.[0] && (
<a
className="vertical-center hyperlink"
target="_blank"
rel="noopener noreferrer"
href={`http://${service.status.loadBalancer.ingress[0].ip}:${service.spec.ports[0].port}`}
>
<Icon icon={ExternalLink} className="!mr-1" />
<span data-cy="k8sAppDetail-containerPort">
Access
</span>
</a>
)}
{!service.status?.loadBalancer?.ingress && (
<div>
{service.spec.externalIPs?.[0]
? service.spec.externalIPs[0]
: 'pending...'}
</div>
)}
</td>
)}
{service.spec?.type !== 'LoadBalancer' && (
<td>
{service.spec?.externalIPs?.[0]
? service.spec.externalIPs[0]
: '-'}
</td>
)}
<td data-cy="k8sAppDetail-containerPort">
{service.spec?.ports?.map((port) => (
<div key={port.port}>{port.targetPort}</div>
))}
</td>
<td>
{service.spec?.ports?.map((port) => (
<div key={port.port}>
{environment?.PublicURL && port.nodePort && (
<a
className="vertical-center hyperlink"
href={`http://${environment?.PublicURL}:${port.nodePort}`}
target="_blank"
rel="noopener noreferrer"
>
<Icon icon={ExternalLink} className="!mr-1" />
<span data-cy="k8sAppDetail-containerPort">
{port.port}
</span>
<span>{port.nodePort ? ' : ' : ''}</span>
<span data-cy="k8sAppDetail-nodePort">
{port.nodePort}/{port.protocol}
</span>
</a>
)}
{!environment?.PublicURL && (
<div>
<span data-cy="k8sAppDetail-servicePort">
{port.port}
</span>
<span>{port.nodePort ? ' : ' : ''}</span>
<span data-cy="k8sAppDetail-nodePort">
{port.nodePort}/{port.protocol}
</span>
</div>
)}
</div>
))}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
);
}

@ -0,0 +1,148 @@
import { KeyToPath, Pod } from 'kubernetes-types/core/v1';
import { Asterisk, Plus } from 'lucide-react';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import { Application } from '../../types';
import { applicationIsKind } from '../../utils';
type Props = {
namespace: string;
app?: Application;
};
export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
const containerVolumeConfigs = getApplicationVolumeConfigs(app);
if (containerVolumeConfigs.length === 0) {
return null;
}
return (
<table className="table">
<tbody>
<tr className="text-muted">
<td className="w-1/4">Container</td>
<td className="w-1/4">Configuration path</td>
<td className="w-1/4">Value</td>
<td className="w-1/4">Configuration</td>
</tr>
{containerVolumeConfigs.map(
(
{
containerVolumeMount,
isInitContainer,
containerName,
item,
volumeConfigName,
},
index
) => (
<tr key={index}>
<td>
{containerName}
{isInitContainer && (
<span>
<Icon icon={Asterisk} />(
<a
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
target="_blank"
rel="noopener noreferrer"
>
init container
</a>
)
</span>
)}
</td>
<td>
{item.path
? `${containerVolumeMount?.mountPath}/${item.path}`
: `${containerVolumeMount?.mountPath}`}
</td>
<td>
{item.key && (
<div className="flex items-center">
<Icon icon={Plus} className="!mr-1" />
{item.key}
</div>
)}
{!item.key && '-'}
</td>
<td>
{volumeConfigName && (
<Link
className="flex items-center"
to="kubernetes.configurations.configuration"
params={{ name: volumeConfigName, namespace }}
>
<Icon icon={Plus} className="!mr-1" />
{volumeConfigName}
</Link>
)}
{!volumeConfigName && '-'}
</td>
</tr>
)
)}
</tbody>
</table>
);
}
// getApplicationVolumeConfigs returns a list of volume configs / secrets for each container and each item within the matching volume
function getApplicationVolumeConfigs(app?: Application) {
if (!app) {
return [];
}
const podSpec = applicationIsKind<Pod>('Pod', app)
? app.spec
: app.spec?.template?.spec;
const appContainers = podSpec?.containers || [];
const appInitContainers = podSpec?.initContainers || [];
const appVolumes = podSpec?.volumes || [];
const allContainers = [...appContainers, ...appInitContainers];
const appVolumeConfigs = allContainers.flatMap((container) => {
// for each container, get the volume mount paths
const matchingVolumes = appVolumes
// filter app volumes by config map or secret
.filter((volume) => volume.configMap || volume.secret)
.flatMap((volume) => {
// flatten by volume items if there are any
const volConfigMapItems =
volume.configMap?.items || volume.secret?.items || [];
const volumeConfigName =
volume.configMap?.name || volume.secret?.secretName;
const containerVolumeMount = container.volumeMounts?.find(
(volumeMount) => volumeMount.name === volume.name
);
if (volConfigMapItems.length === 0) {
return [
{
volumeConfigName,
containerVolumeMount,
containerName: container.name,
isInitContainer: appInitContainers.includes(container),
item: {} as KeyToPath,
},
];
}
// if there are items, return a volume config for each item
return volConfigMapItems.map((item) => ({
volumeConfigName,
containerVolumeMount,
containerName: container.name,
isInitContainer: appInitContainers.includes(container),
item,
}));
})
// only return the app volumes where the container volumeMounts include the volume name (from map step above)
.filter((volume) => volume.containerVolumeMount);
return matchingVolumes;
});
return appVolumeConfigs;
}

@ -0,0 +1,101 @@
import { RotateCw } from 'lucide-react';
import { Pod } from 'kubernetes-types/core/v1';
import { useRouter } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess, notifyError } from '@/portainer/services/notifications';
import { Authorized } from '@/react/hooks/useUser';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import { Button } from '@@/buttons';
import { Icon } from '@@/Icon';
import { useRedeployApplicationMutation } from '../../application.queries';
import { Application } from '../../types';
import {
applicationIsKind,
matchLabelsToLabelSelectorValue,
} from '../../utils';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appName: string;
app?: Application;
};
export function RedeployApplicationButton({
environmentId,
namespace,
appName,
app,
}: Props) {
const router = useRouter();
const redeployAppMutation = useRedeployApplicationMutation(
environmentId,
namespace,
appName
);
return (
<Authorized authorizations="K8sPodDelete">
<Button
type="button"
size="small"
color="light"
className="!ml-0"
disabled={redeployAppMutation.isLoading || !app}
onClick={() => redeployApplication()}
data-cy="k8sAppDetail-redeployButton"
>
<Icon icon={RotateCw} className="mr-1" />
Redeploy
</Button>
</Authorized>
);
async function redeployApplication() {
// validate
if (!app || applicationIsKind<Pod>('Pod', app)) {
return;
}
try {
if (!app?.spec?.selector?.matchLabels) {
throw new Error(
`Application has no 'matchLabels' selector to redeploy pods.`
);
}
} catch (error) {
notifyError('Failure', error as Error);
return;
}
// confirm the action
const confirmed = await confirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
confirmButton: buildConfirmButton('Redeploy'),
message:
'Redeploying terminates and restarts the application, which will cause service interruption. Do you wish to continue?',
});
if (!confirmed) {
return;
}
// using the matchlabels object, delete the associated pods with redeployAppMutation
const labelSelector = matchLabelsToLabelSelectorValue(
app?.spec?.selector?.matchLabels
);
redeployAppMutation.mutateAsync(
{ labelSelector },
{
onSuccess: () => {
notifySuccess('Success', 'Application successfully redeployed');
router.stateService.reload();
},
}
);
}
}

@ -0,0 +1,19 @@
import { RefreshCw } from 'lucide-react';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { BETeaserButton } from '@@/BETeaserButton';
export function RestartApplicationButton() {
return (
<BETeaserButton
buttonClassName="!ml-0"
data-cy="k8sAppDetail-restartButton"
heading="Rolling restart"
icon={RefreshCw}
featureId={FeatureId.K8S_ROLLING_RESTART}
message="A rolling restart of the application is performed."
buttonText="Rolling restart"
/>
);
}

@ -0,0 +1,130 @@
import { Pod } from 'kubernetes-types/core/v1';
import { RotateCcw } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { Authorized } from '@/react/hooks/useUser';
import { notifySuccess, notifyError } from '@/portainer/services/notifications';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Button } from '@@/buttons';
import { Icon } from '@@/Icon';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import {
useApplicationRevisionList,
usePatchApplicationMutation,
} from '../../application.queries';
import {
applicationIsKind,
getRollbackPatchPayload,
matchLabelsToLabelSelectorValue,
} from '../../utils';
import { Application } from '../../types';
import { appDeployMethodLabel } from '../../constants';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appName: string;
app?: Application;
};
export function RollbackApplicationButton({
environmentId,
namespace,
appName,
app,
}: Props) {
const router = useRouter();
const labelSelector = applicationIsKind<Pod>('Pod', app)
? ''
: matchLabelsToLabelSelectorValue(app?.spec?.selector?.matchLabels);
const appRevisionListQuery = useApplicationRevisionList(
environmentId,
namespace,
appName,
app?.metadata?.uid,
labelSelector,
app?.kind
);
const appRevisionList = appRevisionListQuery.data;
const appRevisions = appRevisionList?.items;
const appDeployMethod =
app?.metadata?.labels?.[appDeployMethodLabel] || 'application form';
const patchAppMutation = usePatchApplicationMutation(
environmentId,
namespace,
appName
);
return (
<Authorized authorizations="K8sApplicationDetailsW">
<Button
ng-if="!ctrl.isExternalApplication()"
type="button"
color="light"
size="small"
className="!ml-0"
disabled={
!app ||
!appRevisions ||
appRevisions?.length < 2 ||
appDeployMethod !== 'application form' ||
patchAppMutation.isLoading
}
onClick={() => rollbackApplication()}
data-cy="k8sAppDetail-rollbackButton"
>
<Icon icon={RotateCcw} className="mr-1" />
Rollback to previous configuration
</Button>
</Authorized>
);
async function rollbackApplication() {
// exit early if the application is a pod or there are no revisions
if (
!app?.kind ||
applicationIsKind<Pod>('Pod', app) ||
!appRevisionList?.items?.length
) {
return;
}
// confirm the action
const confirmed = await confirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
confirmButton: buildConfirmButton('Rollback'),
message:
'Rolling back the application to a previous configuration may cause service interruption. Do you wish to continue?',
});
if (!confirmed) {
return;
}
try {
const patch = getRollbackPatchPayload(app, appRevisionList);
patchAppMutation.mutateAsync(
{ appKind: app.kind, patch },
{
onSuccess: () => {
notifySuccess('Success', 'Application successfully rolled back');
router.stateService.reload();
},
onError: (error) =>
notifyError(
'Failure',
error as Error,
'Unable to rollback the application'
),
}
);
} catch (error) {
notifyError('Failure', error as Error);
}
}
}

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

@ -32,7 +32,7 @@ import {
useApplication,
usePatchApplicationMutation,
} from '../application.queries';
import { Application } from '../types';
import { Application, ApplicationPatch } from '../types';
export function ApplicationSummaryWidget() {
const stateAndParams = useCurrentStateAndParams();
@ -263,14 +263,18 @@ export function ApplicationSummaryWidget() {
);
async function patchApplicationNote() {
const path = `/metadata/annotations/${appNoteAnnotation}`;
const value = applicationNoteFormValues;
const patch: ApplicationPatch = [
{
op: 'replace',
path: `/metadata/annotations/${appNoteAnnotation}`,
value: 'applicationNoteFormValues',
},
];
if (application?.kind) {
try {
await patchApplicationMutation.mutateAsync({
appKind: application.kind,
path,
value,
patch,
});
notifySuccess('Success', 'Application successfully updated');
} catch (error) {

@ -1 +1,2 @@
export { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
export { ApplicationDetailsWidget } from './ApplicationDetailsWidget/ApplicationDetailsWidget';

@ -1,14 +1,21 @@
import { useMutation, useQuery } from 'react-query';
import { Pod } from 'kubernetes-types/core/v1';
import { queryClient, withError } from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { getNamespaceServices } from '../ServicesView/service';
import {
getApplicationsForCluster,
getApplication,
patchApplication,
getApplicationRevisionList,
} from './application.service';
import { AppKind } from './types';
import type { AppKind, Application, ApplicationPatch } from './types';
import { deletePod, getNamespacePods } from './pod.service';
import { getNamespaceHorizontalPodAutoscalers } from './autoscaling.service';
import { applicationIsKind, matchLabelsToLabelSelectorValue } from './utils';
const queryKeys = {
applicationsForCluster: (environmentId: EnvironmentId) => [
@ -29,6 +36,73 @@ const queryKeys = {
namespace,
name,
],
applicationRevisions: (
environmentId: EnvironmentId,
namespace: string,
name: string,
labelSelector?: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'revisions',
labelSelector,
],
applicationServices: (
environmentId: EnvironmentId,
namespace: string,
name: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'services',
],
ingressesForApplication: (
environmentId: EnvironmentId,
namespace: string,
name: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'ingresses',
],
applicationHorizontalPodAutoscalers: (
environmentId: EnvironmentId,
namespace: string,
name: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'horizontalpodautoscalers',
],
applicationPods: (
environmentId: EnvironmentId,
namespace: string,
name: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'pods',
],
};
// useQuery to get a list of all applications from an array of namespaces
@ -62,6 +136,161 @@ export function useApplication(
);
}
// test if I can get the previous revision
// useQuery to get an application's previous revision by environmentId, namespace, appKind and labelSelector
export function useApplicationRevisionList(
environmentId: EnvironmentId,
namespace: string,
name: string,
deploymentUid?: string,
labelSelector?: string,
appKind?: AppKind
) {
return useQuery(
queryKeys.applicationRevisions(
environmentId,
namespace,
name,
labelSelector
),
() =>
getApplicationRevisionList(
environmentId,
namespace,
deploymentUid,
appKind,
labelSelector
),
{
...withError('Unable to retrieve application revisions'),
enabled: !!labelSelector && !!appKind && !!deploymentUid,
}
);
}
// useApplicationServices returns a query for services that are related to the application (this doesn't include ingresses)
// Filtering the services by the application selector labels is done in the front end because:
// - The label selector query param in the kubernetes API filters by metadata.labels, but we need to filter the services by spec.selector
// - The field selector query param in the kubernetes API can filter the services by spec.selector, but it doesn't support chaining with 'OR',
// so we can't filter by services with at least one matching label. See: https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/#chained-selectors
export function useApplicationServices(
environmentId: EnvironmentId,
namespace: string,
appName: string,
app?: Application
) {
return useQuery(
queryKeys.applicationServices(environmentId, namespace, appName),
async () => {
if (!app) {
return [];
}
// get the selector labels for the application
const appSelectorLabels = applicationIsKind<Pod>('Pod', app)
? app.metadata?.labels
: app.spec?.template?.metadata?.labels;
// get all services in the namespace and filter them by the application selector labels
const services = await getNamespaceServices(environmentId, namespace);
const filteredServices = services.filter((service) => {
if (service.spec?.selector && appSelectorLabels) {
const serviceSelectorLabels = service.spec.selector;
// include the service if the service selector label matches at least one application selector label
return Object.keys(appSelectorLabels).some(
(key) =>
serviceSelectorLabels[key] &&
serviceSelectorLabels[key] === appSelectorLabels[key]
);
}
return false;
});
return filteredServices;
},
{ ...withError(`Unable to get services for ${appName}`), enabled: !!app }
);
}
// useApplicationHorizontalPodAutoscalers returns a query for horizontal pod autoscalers that are related to the application
export function useApplicationHorizontalPodAutoscalers(
environmentId: EnvironmentId,
namespace: string,
appName: string,
app?: Application
) {
return useQuery(
queryKeys.applicationHorizontalPodAutoscalers(
environmentId,
namespace,
appName
),
async () => {
if (!app) {
return null;
}
const horizontalPodAutoscalers =
await getNamespaceHorizontalPodAutoscalers(environmentId, namespace);
const filteredHorizontalPodAutoscalers =
horizontalPodAutoscalers.find((horizontalPodAutoscaler) => {
const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef;
if (scaleTargetRef) {
const scaleTargetRefName = scaleTargetRef.name;
const scaleTargetRefKind = scaleTargetRef.kind;
// include the horizontal pod autoscaler if the scale target ref name and kind match the application name and kind
return (
scaleTargetRefName === app.metadata?.name &&
scaleTargetRefKind === app.kind
);
}
return false;
}) || null;
return filteredHorizontalPodAutoscalers;
},
{
...withError(
`Unable to get horizontal pod autoscalers${
app ? ` for ${app.metadata?.name}` : ''
}`
),
enabled: !!app,
}
);
}
// useApplicationPods returns a query for pods that are related to the application by the application selector labels
export function useApplicationPods(
environmentId: EnvironmentId,
namespace: string,
appName: string,
app?: Application
) {
return useQuery(
queryKeys.applicationPods(environmentId, namespace, appName),
async () => {
if (applicationIsKind<Pod>('Pod', app)) {
return [app];
}
const appSelector = app?.spec?.selector;
const labelSelector = matchLabelsToLabelSelectorValue(
appSelector?.matchLabels
);
// get all pods in the namespace using the application selector as the label selector query param
const pods = await getNamespacePods(
environmentId,
namespace,
labelSelector
);
return pods;
},
{
...withError(`Unable to get pods for ${appName}`),
enabled: !!app,
}
);
}
// useQuery to patch an application by environmentId, namespace, name and patch payload
export function usePatchApplicationMutation(
environmentId: EnvironmentId,
@ -69,22 +298,54 @@ export function usePatchApplicationMutation(
name: string
) {
return useMutation(
({
appKind,
path,
value,
}: {
appKind: AppKind;
path: string;
value: string;
}) =>
patchApplication(environmentId, namespace, appKind, name, path, value),
({ appKind, patch }: { appKind: AppKind; patch: ApplicationPatch }) =>
patchApplication(environmentId, namespace, appKind, name, patch),
{
onSuccess: () => {
queryClient.invalidateQueries(
queryKeys.application(environmentId, namespace, name)
);
},
// patch application is used for patching and rollbacks, so handle the error where it's used instead of here
}
);
}
// useRedeployApplicationMutation gets all the pods for an application (using the matchLabels field in the labelSelector query param) and then deletes all of them, so that they are recreated
export function useRedeployApplicationMutation(
environmentId: number,
namespace: string,
name: string
) {
return useMutation(
async ({ labelSelector }: { labelSelector: string }) => {
try {
// get only the pods that match the labelSelector for the application
const pods = await getNamespacePods(
environmentId,
namespace,
labelSelector
);
// delete all the pods to redeploy the application
await Promise.all(
pods.map((pod) => {
if (pod?.metadata?.name) {
return deletePod(environmentId, namespace, pod.metadata.name);
}
return Promise.resolve();
})
);
} catch (error) {
throw new Error(`Unable to redeploy application: ${error}`);
}
},
{
onSuccess: () => {
queryClient.invalidateQueries(
queryKeys.application(environmentId, namespace, name)
);
},
...withError('Unable to redeploy application'),
}
);
}

@ -5,15 +5,23 @@ import {
Deployment,
DaemonSet,
StatefulSet,
ReplicaSetList,
ControllerRevisionList,
} from 'kubernetes-types/apps/v1';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { isFulfilled } from '@/portainer/helpers/promise-utils';
import { getPod, getPods, patchPod } from './pod.service';
import { getNakedPods } from './utils';
import { AppKind, Application, ApplicationList } from './types';
import { getPod, getNamespacePods, patchPod } from './pod.service';
import { filterRevisionsByOwnerUid, getNakedPods } from './utils';
import {
AppKind,
Application,
ApplicationList,
ApplicationPatch,
} from './types';
import { appRevisionAnnotation } from './constants';
// This file contains services for Kubernetes apps/v1 resources (Deployments, DaemonSets, StatefulSets)
@ -58,7 +66,7 @@ async function getApplicationsForNamespace(
namespace,
'StatefulSet'
),
getPods(environmentId, namespace),
getNamespacePods(environmentId, namespace),
]);
// find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset)
const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets);
@ -147,8 +155,7 @@ export async function patchApplication(
namespace: string,
appKind: AppKind,
name: string,
path: string,
value: string
patch: ApplicationPatch
) {
try {
switch (appKind) {
@ -158,8 +165,7 @@ export async function patchApplication(
namespace,
appKind,
name,
path,
value
patch
);
case 'DaemonSet':
return await patchApplicationByKind<DaemonSet>(
@ -167,8 +173,8 @@ export async function patchApplication(
namespace,
appKind,
name,
path,
value
patch,
'application/strategic-merge-patch+json'
);
case 'StatefulSet':
return await patchApplicationByKind<StatefulSet>(
@ -176,11 +182,11 @@ export async function patchApplication(
namespace,
appKind,
name,
path,
value
patch,
'application/strategic-merge-patch+json'
);
case 'Pod':
return await patchPod(environmentId, namespace, name, path, value);
return await patchPod(environmentId, namespace, name, patch);
default:
throw new Error(`Unknown application kind ${appKind}`);
}
@ -197,23 +203,16 @@ async function patchApplicationByKind<T extends Application>(
namespace: string,
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
name: string,
path: string,
value: string
patch: ApplicationPatch,
contentType = 'application/json-patch+json'
) {
const payload = [
{
op: 'replace',
path,
value,
},
];
try {
const res = await axios.patch<T>(
buildUrl(environmentId, namespace, `${appKind}s`, name),
payload,
patch,
{
headers: {
'Content-Type': 'application/json-patch+json',
'Content-Type': contentType,
},
}
);
@ -254,10 +253,120 @@ async function getApplicationsByKind<T extends ApplicationList>(
}
}
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 parseAxiosError(e as Error, '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 parseAxiosError(e as Error, 'Unable to retrieve ControllerRevisions');
}
}
function buildUrl(
environmentId: EnvironmentId,
namespace: string,
appKind: 'Deployments' | 'DaemonSets' | 'StatefulSets',
appKind:
| 'Deployments'
| 'DaemonSets'
| 'StatefulSets'
| 'ReplicaSets'
| 'ControllerRevisions',
name?: string
) {
let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`;

@ -0,0 +1,14 @@
import { HorizontalPodAutoscalerList } from 'kubernetes-types/autoscaling/v1';
import axios from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
export async function getNamespaceHorizontalPodAutoscalers(
environmentId: EnvironmentId,
namespace: string
) {
const { data: autoScalarList } = await axios.get<HorizontalPodAutoscalerList>(
`/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers`
);
return autoScalarList.items;
}

@ -2,9 +2,25 @@ import { AppKind, DeploymentType } from './types';
// Portainer specific labels
export const appStackNameLabel = 'io.portainer.kubernetes.application.stack';
export const appStackIdLabel = 'io.portainer.kubernetes.application.stackid';
export const appOwnerLabel = 'io.portainer.kubernetes.application.owner';
export const appNoteAnnotation = 'io.portainer.kubernetes.application.note';
export const appDeployMethodLabel = 'io.portainer.kubernetes.application.kind';
export const defaultDeploymentUniqueLabel = 'pod-template-hash';
export const appRevisionAnnotation = 'deployment.kubernetes.io/revision';
// unchangedAnnotationKeysForRollbackPatch lists the annotations that should be preserved from the deployment and not
// copied from the replicaset when rolling a deployment back
export const unchangedAnnotationKeysForRollbackPatch = [
'kubectl.kubernetes.io/last-applied-configuration',
appRevisionAnnotation,
'deployment.kubernetes.io/revision-history',
'deployment.kubernetes.io/desired-replicas',
'deployment.kubernetes.io/max-replicas',
'deprecated.deployment.rollback.to',
'deprecated.daemonset.template.generation',
];
export const appKindToDeploymentTypeMap: Record<
AppKind,

@ -1,12 +1,23 @@
import { Pod, PodList } from 'kubernetes-types/core/v1';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { ApplicationPatch } from './types';
export async function getPods(environmentId: EnvironmentId, namespace: string) {
export async function getNamespacePods(
environmentId: EnvironmentId,
namespace: string,
labelSelector?: string
) {
try {
const { data } = await axios.get<PodList>(
buildUrl(environmentId, namespace)
buildUrl(environmentId, namespace),
{
params: {
labelSelector,
},
}
);
return data.items;
} catch (e) {
@ -33,20 +44,12 @@ export async function patchPod(
environmentId: EnvironmentId,
namespace: string,
name: string,
path: string,
value: string
patch: ApplicationPatch
) {
const payload = [
{
op: 'replace',
path,
value,
},
];
try {
return await axios.patch<Pod>(
buildUrl(environmentId, namespace, name),
payload,
patch,
{
headers: {
'Content-Type': 'application/json-patch+json',
@ -58,6 +61,18 @@ export async function patchPod(
}
}
export async function deletePod(
environmentId: EnvironmentId,
namespace: string,
name: string
) {
try {
return await axios.delete<Pod>(buildUrl(environmentId, namespace, name));
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to delete pod');
}
}
export function buildUrl(
environmentId: EnvironmentId,
namespace: string,

@ -5,11 +5,18 @@ import {
DeploymentList,
StatefulSet,
StatefulSetList,
ReplicaSet,
ControllerRevision,
} from 'kubernetes-types/apps/v1';
import { Pod, PodList } from 'kubernetes-types/core/v1';
import { RawExtension } from 'kubernetes-types/runtime';
export type Application = Deployment | DaemonSet | StatefulSet | Pod;
// Revisions are have the previous application state and are used for rolling back applications to their previous state.
// Deployments use ReplicaSets, StatefulSets and DaemonSets use ControllerRevisions, and Pods don't have revisions.
export type Revision = ReplicaSet | ControllerRevision;
export type ApplicationList =
| DeploymentList
| DaemonSetList
@ -19,3 +26,11 @@ export type ApplicationList =
export type AppKind = 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod';
export type DeploymentType = 'Replicated' | 'Global';
type Patch = {
op: 'replace' | 'add' | 'remove';
path: string;
value: string | number | boolean | null | Record<string, unknown>;
}[];
export type ApplicationPatch = Patch | RawExtension;

@ -1,18 +1,32 @@
import { Deployment, DaemonSet, StatefulSet } from 'kubernetes-types/apps/v1';
import {
Deployment,
DaemonSet,
StatefulSet,
ReplicaSet,
ReplicaSetList,
ControllerRevisionList,
ControllerRevision,
} from 'kubernetes-types/apps/v1';
import { Pod } from 'kubernetes-types/core/v1';
import filesizeParser from 'filesize-parser';
import { Application } from './types';
import { appOwnerLabel } from './constants';
import { Application, ApplicationPatch, Revision } from './types';
import {
appOwnerLabel,
defaultDeploymentUniqueLabel,
unchangedAnnotationKeysForRollbackPatch,
appRevisionAnnotation,
} from './constants';
// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset
// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs
// getNakedPods returns an array of naked pods from an array of pods, deployments, daemonsets and statefulsets
export function getNakedPods(
pods: Pod[],
deployments: Deployment[],
daemonSets: DaemonSet[],
statefulSets: StatefulSet[]
) {
// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset
// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs
const appLabels = [
...deployments.map((deployment) => deployment.spec?.selector.matchLabels),
...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels),
@ -37,7 +51,7 @@ export function getNakedPods(
return nakedPods;
}
// type guard to check if an application is a deployment, daemonset statefulset or pod
// type guard to check if an application is a deployment, daemonset, statefulset or pod
export function applicationIsKind<T extends Application>(
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod',
application?: Application
@ -165,3 +179,151 @@ export function getResourceLimits(application: Application) {
return limits;
}
// matchLabelsToLabelSelectorValue converts a map of labels to a label selector value that can be used in the
// labelSelector param for the kube api to filter kube resources by labels
export function matchLabelsToLabelSelectorValue(obj?: Record<string, string>) {
if (!obj) return '';
return Object.entries(obj)
.map(([key, value]) => `${key}=${value}`)
.join(',');
}
// filterRevisionsByOwnerUid filters a list of revisions to only include revisions that have the given uid in their
// ownerReferences
export function filterRevisionsByOwnerUid<T extends Revision>(
revisions: T[],
uid: string
) {
return revisions.filter((revision) => {
const ownerReferencesUids =
revision.metadata?.ownerReferences?.map((or) => or.uid) || [];
return ownerReferencesUids.includes(uid);
});
}
// getRollbackPatchPayload returns the patch payload to rollback a deployment to the previous revision
// the patch should be able to update the deployment's template to the previous revision's template
export function getRollbackPatchPayload(
application: Deployment | StatefulSet | DaemonSet,
revisionList: ReplicaSetList | ControllerRevisionList
): ApplicationPatch {
switch (revisionList.kind) {
case 'ControllerRevisionList': {
const previousRevision = getPreviousControllerRevision(
revisionList.items
);
if (!previousRevision.data) {
throw new Error('No data found in the previous revision.');
}
return previousRevision.data;
}
case 'ReplicaSetList': {
const previousRevision = getPreviousReplicaSetRevision(
revisionList.items
);
// remove hash label before patching back into the deployment
const revisionTemplate = previousRevision.spec?.template;
if (revisionTemplate?.metadata?.labels) {
delete revisionTemplate.metadata.labels[defaultDeploymentUniqueLabel];
}
// build the patch payload for the deployment from the replica set
// keep the annotations to skip from the deployment, in the patch
const applicationAnnotations = application.metadata?.annotations || {};
const applicationAnnotationsInPatch =
unchangedAnnotationKeysForRollbackPatch.reduce((acc, annotationKey) => {
if (applicationAnnotations[annotationKey]) {
acc[annotationKey] = applicationAnnotations[annotationKey];
}
return acc;
}, {} as Record<string, string>);
// add any annotations from the target revision that shouldn't be skipped
const revisionAnnotations = previousRevision.metadata?.annotations || {};
const revisionAnnotationsInPatch = Object.entries(
revisionAnnotations
).reduce((acc, [annotationKey, annotationValue]) => {
if (!unchangedAnnotationKeysForRollbackPatch.includes(annotationKey)) {
acc[annotationKey] = annotationValue;
}
return acc;
}, {} as Record<string, string>);
const patchAnnotations = {
...applicationAnnotationsInPatch,
...revisionAnnotationsInPatch,
};
// Create a patch of the Deployment that replaces spec.template
const deploymentRollbackPatch = [
{
op: 'replace',
path: '/spec/template',
value: revisionTemplate,
},
{
op: 'replace',
path: '/metadata/annotations',
value: patchAnnotations,
},
].filter((p) => !!p.value); // remove any patch that has no value
return deploymentRollbackPatch;
}
default:
throw new Error(`Unknown revision list kind ${revisionList.kind}.`);
}
}
function getPreviousReplicaSetRevision(replicaSets: ReplicaSet[]) {
// sort replicaset(s) using the revision annotation number (old to new).
// Kubectl uses the same revision annotation key to determine the previous version
// (see the Revision function, and where it's used https://github.com/kubernetes/kubectl/blob/27ec3dafa658d8873b3d9287421d636048b51921/pkg/util/deployment/deployment.go#LL70C11-L70C11)
const sortedReplicaSets = replicaSets.sort((a, b) => {
const aRevision =
Number(a.metadata?.annotations?.[appRevisionAnnotation]) || 0;
const bRevision =
Number(b.metadata?.annotations?.[appRevisionAnnotation]) || 0;
return aRevision - bRevision;
});
// if there are less than 2 revisions, there is no previous revision to rollback to
if (sortedReplicaSets.length < 2) {
throw new Error(
'There are no previous revisions to rollback to. Please check the application revisions.'
);
}
// get the second to last revision
const previousRevision = sortedReplicaSets[sortedReplicaSets.length - 2];
return previousRevision;
}
function getPreviousControllerRevision(
controllerRevisions: ControllerRevision[]
) {
// sort the list of ControllerRevisions by revision, using the creationTimestamp as a tie breaker (old to new)
const sortedControllerRevisions = controllerRevisions.sort((a, b) => {
if (a.revision === b.revision) {
return (
new Date(a.metadata?.creationTimestamp || '').getTime() -
new Date(b.metadata?.creationTimestamp || '').getTime()
);
}
return a.revision - b.revision;
});
// if there are less than 2 revisions, there is no previous revision to rollback to
if (sortedControllerRevisions.length < 2) {
throw new Error(
'There are no previous revisions to rollback to. Please check the application revisions.'
);
}
// get the second to last revision
const previousRevision =
sortedControllerRevisions[sortedControllerRevisions.length - 2];
return previousRevision;
}

@ -1,7 +1,7 @@
import { PropsWithChildren, ReactNode } from 'react';
import { SchemaOf, string } from 'yup';
import { StackId } from '@/react/docker/stacks/types';
import { StackId } from '@/react/common/stacks/types';
import { useStateWrapper } from '@/react/hooks/useStateWrapper';
import { FormControl } from '@@/form-components/FormControl';

@ -1,4 +1,4 @@
import { StackId } from '@/react/docker/stacks/types';
import { StackId } from '@/react/common/stacks/types';
import { useGitRefs } from '@/react/portainer/gitops/queries/useGitRefs';
import { Select } from '@@/form-components/Input';

Loading…
Cancel
Save