mirror of https://github.com/portainer/portainer
refactor(app): details widget migration [EE-5352] (#8886)
parent
fdd79cece8
commit
af77e33993
@ -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;
|
@ -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);
|
@ -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: '<',
|
||||
},
|
||||
});
|
@ -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;
|
||||
}
|
@ -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';
|
@ -1 +1,2 @@
|
||||
export { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
|
||||
export { ApplicationDetailsWidget } from './ApplicationDetailsWidget/ApplicationDetailsWidget';
|
||||
|
@ -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;
|
||||
}
|
Loading…
Reference in new issue