mirror of https://github.com/portainer/portainer
refactor(app): details widget migration [EE-5352] (#8886)
parent
fdd79cece8
commit
af77e33993
|
@ -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';
|
||||
|
||||
export async function getPods(environmentId: EnvironmentId, namespace: string) {
|
||||
import { ApplicationPatch } from './types';
|
||||
|
||||
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…
Reference in New Issue