1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

refactor(app): migrate app summary section [EE-6239] (#10910)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run

This commit is contained in:
Ali 2024-01-05 15:42:36 +13:00 committed by GitHub
parent 7a4314032a
commit abf517de28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1461 additions and 661 deletions

View file

@ -2,11 +2,13 @@ import { BoxSelector } from '@@/BoxSelector';
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { DeploymentType } from '../types';
import { getDeploymentOptions } from './deploymentOptions';
interface Props {
value: number;
onChange(value: number): void;
value: DeploymentType;
onChange(value: DeploymentType): void;
supportGlobalDeployment: boolean;
}

View file

@ -1,14 +1,14 @@
import { Box, Boxes } from 'lucide-react';
import { KubernetesApplicationDataAccessPolicies } from '@/kubernetes/models/application/models';
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
import { AppDataAccessPolicy } from '../types';
interface Props {
isEdit: boolean;
persistedFoldersUseExistingVolumes: boolean;
value: number;
onChange(value: number): void;
value: AppDataAccessPolicy;
onChange(value: AppDataAccessPolicy): void;
}
export function DataAccessPolicyFormSection({
@ -31,13 +31,13 @@ export function DataAccessPolicyFormSection({
}
function getOptions(
value: number,
value: AppDataAccessPolicy,
isEdit: boolean,
persistedFoldersUseExistingVolumes: boolean
): ReadonlyArray<BoxSelectorOption<number>> {
): ReadonlyArray<BoxSelectorOption<AppDataAccessPolicy>> {
return [
{
value: KubernetesApplicationDataAccessPolicies.ISOLATED,
value: 'Isolated',
id: 'data_access_isolated',
icon: Boxes,
iconType: 'badge',
@ -49,12 +49,10 @@ function getOptions(
? 'Changing the data access policy is not allowed'
: '',
disabled: () =>
(isEdit &&
value !== KubernetesApplicationDataAccessPolicies.ISOLATED) ||
persistedFoldersUseExistingVolumes,
(isEdit && value !== 'Isolated') || persistedFoldersUseExistingVolumes,
},
{
value: KubernetesApplicationDataAccessPolicies.SHARED,
value: 'Shared',
id: 'data_access_shared',
icon: Box,
iconType: 'badge',
@ -63,8 +61,7 @@ function getOptions(
'Application will be deployed as a Deployment with a shared storage access',
tooltip: () =>
isEdit ? 'Changing the data access policy is not allowed' : '',
disabled: () =>
isEdit && value !== KubernetesApplicationDataAccessPolicies.SHARED,
disabled: () => isEdit && value !== 'Shared',
},
] as const;
}

View file

@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import {
useIngressControllers,
@ -10,12 +9,7 @@ import {
import { FormSection } from '@@/form-components/FormSection';
import {
ServiceFormValues,
ServiceTypeAngularEnum,
ServiceTypeOption,
ServiceTypeValue,
} from './types';
import { ServiceFormValues, ServiceTypeOption, ServiceType } from './types';
import { generateUniqueName } from './utils';
import { ClusterIpServicesForm } from './cluster-ip/ClusterIpServicesForm';
import { ServiceTabs } from './components/ServiceTabs';
@ -24,15 +18,6 @@ import { LoadBalancerServicesForm } from './load-balancer/LoadBalancerServicesFo
import { ServiceTabLabel } from './components/ServiceTabLabel';
import { PublishingExplaination } from './PublishingExplaination';
const serviceTypeEnumsToValues: Record<
ServiceTypeAngularEnum,
ServiceTypeValue
> = {
[KubernetesApplicationPublishingTypes.CLUSTER_IP]: 'ClusterIP',
[KubernetesApplicationPublishingTypes.NODE_PORT]: 'NodePort',
[KubernetesApplicationPublishingTypes.LOAD_BALANCER]: 'LoadBalancer',
};
interface Props {
values: ServiceFormValues[];
onChange: (services: ServiceFormValues[]) => void;
@ -53,7 +38,7 @@ export function KubeServicesForm({
namespace,
}: Props) {
const [selectedServiceType, setSelectedServiceType] =
useState<ServiceTypeValue>('ClusterIP');
useState<ServiceType>('ClusterIP');
// start loading ingresses and controllers early to reduce perceived loading time
const environmentId = useEnvironmentId();
@ -195,17 +180,17 @@ function getUniqNames(appName: string, services: ServiceFormValues[]) {
*/
function getServiceTypeCounts(
services: ServiceFormValues[]
): Record<ServiceTypeValue, number> {
): Record<ServiceType, number> {
return services.reduce(
(acc, service) => {
const type = serviceTypeEnumsToValues[service.Type];
const type = service.Type;
const count = acc[type];
return {
...acc,
[type]: count ? count + 1 : 1,
};
},
{} as Record<ServiceTypeValue, number>
{} as Record<ServiceType, number>
);
}
@ -215,10 +200,10 @@ function getServiceTypeCounts(
function getServiceTypeHasErrors(
services: ServiceFormValues[],
errors: FormikErrors<ServiceFormValues[] | undefined>
): Record<ServiceTypeValue, boolean> {
): Record<ServiceType, boolean> {
return services.reduce(
(acc, service, index) => {
const type = serviceTypeEnumsToValues[service.Type];
const type = service.Type;
const serviceHasErrors = !!errors?.[index];
// if the service type already has an error, don't overwrite it
if (acc[type] === true) return acc;
@ -228,6 +213,6 @@ function getServiceTypeHasErrors(
[type]: serviceHasErrors,
};
},
{} as Record<ServiceTypeValue, boolean>
{} as Record<ServiceType, boolean>
);
}

View file

@ -88,10 +88,7 @@ export function ClusterIpServiceForm({
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
const newValue = e.target.valueAsNumber;
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
targetPort: newValue,
@ -113,10 +110,7 @@ export function ClusterIpServiceForm({
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
port: e.target.valueAsNumber,
};
onChangePort(newServicePorts);
}}

View file

@ -1,8 +1,6 @@
import { Plus } from 'lucide-react';
import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { Card } from '@@/Card';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
@ -36,8 +34,7 @@ export function ClusterIpServicesForm({
isEditMode,
}: Props) {
const clusterIPServiceCount = services.filter(
(service) =>
service.Type === KubernetesApplicationPublishingTypes.CLUSTER_IP
(service) => service.Type === 'ClusterIP'
).length;
return (
<Card className="pb-5">
@ -50,8 +47,7 @@ export function ClusterIpServicesForm({
{clusterIPServiceCount > 0 && (
<div className="flex w-full flex-col gap-4">
{services.map((service, index) =>
service.Type ===
KubernetesApplicationPublishingTypes.CLUSTER_IP ? (
service.Type === 'ClusterIP' ? (
<ClusterIpServiceForm
key={index}
serviceName={service.Name}
@ -86,7 +82,7 @@ export function ClusterIpServicesForm({
services.length + 1,
services
);
newService.Type = KubernetesApplicationPublishingTypes.CLUSTER_IP;
newService.Type = 'ClusterIP';
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;

View file

@ -22,7 +22,7 @@ export function ContainerPortInput({
type="number"
className="form-control min-w-max"
name={`container_port_${portIndex}`}
placeholder="80"
placeholder="e.g. 80"
min="1"
max="65535"
value={value ?? ''}

View file

@ -22,7 +22,7 @@ export function ServicePortInput({
type="number"
className="form-control min-w-max"
name={`service_port_${portIndex}`}
placeholder="80"
placeholder="e.g. 80"
min="1"
max="65535"
value={value ?? ''}

View file

@ -1,11 +1,11 @@
import clsx from 'clsx';
import { ServiceTypeOption, ServiceTypeValue } from '../types';
import { ServiceTypeOption, ServiceType } from '../types';
type Props = {
serviceTypeOptions: ServiceTypeOption[];
selectedServiceType: ServiceTypeValue;
setSelectedServiceType: (serviceTypeValue: ServiceTypeValue) => void;
selectedServiceType: ServiceType;
setSelectedServiceType: (serviceTypeValue: ServiceType) => void;
};
export function ServiceTabs({
@ -32,7 +32,7 @@ export function ServiceTabs({
value={serviceTypeOptions[index].value}
checked={selectedServiceType === serviceTypeOptions[index].value}
onChange={(e) =>
setSelectedServiceType(e.target.value as ServiceTypeValue)
setSelectedServiceType(e.target.value as ServiceType)
}
/>
{label}

View file

@ -1,7 +1,5 @@
import { SchemaOf, array, object, boolean, string, mixed, number } from 'yup';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { ServiceFormValues, ServicePort } from './types';
import { prependWithSlash } from './utils';
@ -46,11 +44,7 @@ export function kubeServicesValidation(
Namespace: string(),
Name: string(),
StackName: string(),
Type: mixed().oneOf([
KubernetesApplicationPublishingTypes.CLUSTER_IP,
KubernetesApplicationPublishingTypes.NODE_PORT,
KubernetesApplicationPublishingTypes.LOAD_BALANCER,
]),
Type: mixed().oneOf(['ClusterIP', 'NodePort', 'LoadBalancer']),
ClusterIP: string(),
ApplicationName: string(),
ApplicationOwner: string(),
@ -61,6 +55,7 @@ export function kubeServicesValidation(
object({
port: number()
.required('Service port number is required.')
.typeError('Service port number is required.')
.min(1, 'Service port number must be inside the range 1-65535.')
.max(65535, 'Service port number must be inside the range 1-65535.')
.test(
@ -93,6 +88,7 @@ export function kubeServicesValidation(
),
targetPort: number()
.required('Container port number is required.')
.typeError('Container port number is required.')
.min(1, 'Container port number must be inside the range 1-65535.')
.max(
65535,
@ -116,8 +112,7 @@ export function kubeServicesValidation(
);
if (
matchingService === undefined ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
matchingService.Type !== 'NodePort'
) {
return true;
}
@ -143,8 +138,7 @@ export function kubeServicesValidation(
if (
matchingService === undefined ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT // ignore validation unless the service is of type nodeport
matchingService.Type !== 'NodePort'
) {
return true;
}
@ -163,8 +157,7 @@ export function kubeServicesValidation(
const formNodePortsWithoutCurrentService = formServices
.filter(
(formService) =>
formService.Type ===
KubernetesApplicationPublishingTypes.NODE_PORT &&
formService.Type === 'NodePort' &&
formService.Name !== matchingService.Name
)
.flatMap((formService) => formService.Ports)
@ -187,11 +180,7 @@ export function kubeServicesValidation(
context.parent as ServicePort,
formServices
);
if (
!matchingService ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT
) {
if (!matchingService || matchingService.Type !== 'NodePort') {
return true;
}
return nodePort >= 30000;
@ -209,11 +198,7 @@ export function kubeServicesValidation(
context.parent as ServicePort,
formServices
);
if (
!matchingService ||
matchingService.Type !==
KubernetesApplicationPublishingTypes.NODE_PORT
) {
if (!matchingService || matchingService.Type !== 'NodePort') {
return true;
}
return nodePort <= 32767;

View file

@ -93,10 +93,7 @@ export function LoadBalancerServiceForm({
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
const newValue = e.target.valueAsNumber;
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
targetPort: newValue,
@ -119,10 +116,7 @@ export function LoadBalancerServiceForm({
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
port: e.target.valueAsNumber,
};
onChangePort(newServicePorts);
}}
@ -140,7 +134,7 @@ export function LoadBalancerServiceForm({
type="number"
className="form-control min-w-max"
name={`loadbalancer_port_${portIndex}`}
placeholder="80"
placeholder="e.g. 80"
min="1"
max="65535"
value={servicePort.port || ''}
@ -148,10 +142,7 @@ export function LoadBalancerServiceForm({
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
port: e.target.valueAsNumber,
};
onChangePort(newServicePorts);
}}

View file

@ -1,7 +1,6 @@
import { Plus, RefreshCw } from 'lucide-react';
import { FormikErrors } from 'formik';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@ -49,8 +48,7 @@ export function LoadBalancerServicesForm({
);
const loadBalancerServiceCount = services.filter(
(service) =>
service.Type === KubernetesApplicationPublishingTypes.LOAD_BALANCER
(service) => service.Type === 'LoadBalancer'
).length;
return (
<Card className="pb-5">
@ -95,8 +93,7 @@ export function LoadBalancerServicesForm({
{loadBalancerServiceCount > 0 && (
<div className="flex w-full flex-col gap-4">
{services.map((service, index) =>
service.Type ===
KubernetesApplicationPublishingTypes.LOAD_BALANCER ? (
service.Type === 'LoadBalancer' ? (
<LoadBalancerServiceForm
key={index}
serviceName={service.Name}
@ -131,8 +128,7 @@ export function LoadBalancerServicesForm({
services.length + 1,
services
);
newService.Type =
KubernetesApplicationPublishingTypes.LOAD_BALANCER;
newService.Type = 'LoadBalancer';
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;

View file

@ -94,10 +94,7 @@ export function NodePortServiceForm({
value={servicePort.targetPort}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newServicePorts = [...servicePorts];
const newValue =
e.target.value === ''
? undefined
: Number(e.target.value);
const newValue = e.target.valueAsNumber;
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
targetPort: newValue,
@ -120,10 +117,7 @@ export function NodePortServiceForm({
const newServicePorts = [...servicePorts];
newServicePorts[portIndex] = {
...newServicePorts[portIndex],
port:
e.target.value === ''
? undefined
: Number(e.target.value),
port: e.target.valueAsNumber,
};
onChangePort(newServicePorts);
}}
@ -139,7 +133,7 @@ export function NodePortServiceForm({
type="number"
className="form-control min-w-max"
name={`node_port_${portIndex}`}
placeholder="30080"
placeholder="e.g. 30080"
min="30000"
max="32767"
value={servicePort.nodePort ?? ''}

View file

@ -1,8 +1,6 @@
import { FormikErrors } from 'formik';
import { Plus } from 'lucide-react';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
import { Card } from '@@/Card';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
@ -36,7 +34,7 @@ export function NodePortServicesForm({
isEditMode,
}: Props) {
const nodePortServiceCount = services.filter(
(service) => service.Type === KubernetesApplicationPublishingTypes.NODE_PORT
(service) => service.Type === 'NodePort'
).length;
return (
<Card className="pb-5">
@ -48,8 +46,7 @@ export function NodePortServicesForm({
{nodePortServiceCount > 0 && (
<div className="flex w-full flex-col gap-4">
{services.map((service, index) =>
service.Type ===
KubernetesApplicationPublishingTypes.NODE_PORT ? (
service.Type === 'NodePort' ? (
<NodePortServiceForm
key={index}
serviceName={service.Name}
@ -84,7 +81,7 @@ export function NodePortServicesForm({
services.length + 1,
services
);
newService.Type = KubernetesApplicationPublishingTypes.NODE_PORT;
newService.Type = 'NodePort';
const newServicePort = newPort(newService.Name);
newService.Ports = [newServicePort];
newService.Selector = selector;

View file

@ -1,9 +1,7 @@
import { ReactNode } from 'react';
import { KubernetesApplicationPublishingTypes } from '@/kubernetes/models/application/models';
export interface ServicePort {
port?: number;
port: number;
targetPort?: number;
nodePort?: number;
serviceName?: string;
@ -12,9 +10,6 @@ export interface ServicePort {
ingressPaths?: ServicePortIngressPath[];
}
export type ServiceTypeAngularEnum =
(typeof KubernetesApplicationPublishingTypes)[keyof typeof KubernetesApplicationPublishingTypes];
export type ServicePortIngressPath = {
IngressName?: string;
Host?: string;
@ -24,7 +19,7 @@ export type ServicePortIngressPath = {
export type ServiceFormValues = {
Headless: boolean;
Ports: ServicePort[];
Type: ServiceTypeAngularEnum;
Type: ServiceType;
Ingress: boolean;
ClusterIP?: string;
ApplicationName?: string;
@ -36,9 +31,9 @@ export type ServiceFormValues = {
Namespace?: string;
};
export type ServiceTypeValue = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
export type ServiceType = 'ClusterIP' | 'NodePort' | 'LoadBalancer';
export type ServiceTypeOption = {
value: ServiceTypeValue;
value: ServiceType;
label: ReactNode;
};

View file

@ -1,10 +1,21 @@
import { Ingress } from '@/react/kubernetes/ingresses/types';
import { compare } from 'fast-json-patch';
import { Service, ServiceSpec } from 'kubernetes-types/core/v1';
import { ObjectMeta } from 'kubernetes-types/meta/v1';
import angular from 'angular';
import { Ingress as IngressFormValues } from '@/react/kubernetes/ingresses/types';
import {
appNameLabel,
appOwnerLabel,
appStackNameLabel,
} from '../../constants';
import { ServiceFormValues, ServicePort } from './types';
export function newPort(serviceName?: string) {
export function newPort(serviceName?: string): ServicePort {
return {
port: undefined,
port: 80,
targetPort: undefined,
name: '',
protocol: 'TCP',
@ -43,7 +54,7 @@ export const serviceFormDefaultValues: ServiceFormValues = {
Name: '',
StackName: '',
Ports: [],
Type: 1, // clusterip type as default
Type: 'ClusterIP',
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
@ -54,16 +65,16 @@ export const serviceFormDefaultValues: ServiceFormValues = {
/**
* Generates new Ingress objects from form path data
* @param {Ingress[]} oldIngresses - The old Ingress objects
* @param {IngressFormValues[]} oldIngresses - The old Ingress objects
* @param {ServicePort[]} newServicesPorts - The new ServicePort objects from the form
* @param {ServicePort[]} oldServicesPorts - The old ServicePort objects
* @returns {Ingress[]} The new Ingress objects
* @returns {IngressFormValues[]} The new Ingress objects
*/
export function generateNewIngressesFromFormPaths(
oldIngresses?: Ingress[],
oldIngresses?: IngressFormValues[],
newServicesPorts?: ServicePort[],
oldServicesPorts?: ServicePort[]
): Ingress[] {
): IngressFormValues[] {
// filter the ports to only the ones that have an ingress
const oldIngressPaths = oldServicesPorts
?.flatMap((port) => port.ingressPaths)
@ -77,7 +88,7 @@ export function generateNewIngressesFromFormPaths(
}
// remove the old paths from the newIngresses copy
const newIngresses = structuredClone(oldIngresses) ?? [];
const newIngresses: IngressFormValues[] = angular.copy(oldIngresses) ?? []; // the current jest version doesn't support structured cloning, so we need to use angular.copy
oldIngressPaths?.forEach((oldIngressPath) => {
if (!oldIngressPath?.Path) return;
const newMatchingIng = newIngresses?.find(
@ -151,3 +162,57 @@ export function prependWithSlash(path?: string) {
if (!path) return '';
return path.startsWith('/') ? path : `/${path}`;
}
export function getServicePatchPayload(
oldService: ServiceFormValues,
newService: ServiceFormValues
) {
const oldPayload = getServicePayload(oldService);
const newPayload = getServicePayload(newService);
const payload = compare(oldPayload, newPayload);
return payload;
}
function getServicePayload(service: ServiceFormValues): Service {
if (!service.Name || !service.Namespace) {
throw new Error('Service name and namespace are required');
}
// metadata
const labels: Record<string, string> = {};
if (service.ApplicationName) {
labels[appNameLabel] = service.ApplicationName;
}
if (service.ApplicationOwner) {
labels[appOwnerLabel] = service.ApplicationOwner;
}
if (service.StackName) {
labels[appStackNameLabel] = service.StackName;
}
const metadata: ObjectMeta = {
name: service.Name,
namespace: service.Namespace,
labels,
};
// spec
const ports = service.Headless ? [] : service.Ports;
const selector = service.Selector;
const clusterIP = service.Headless ? 'None' : service.ClusterIP;
const type = service.Headless ? 'ClusterIP' : service.Type;
const spec: ServiceSpec = {
ports,
selector,
clusterIP,
type,
};
const servicePayload: Service = {
apiVersion: 'v1',
kind: 'Service',
metadata,
spec,
};
return servicePayload;
}

View file

@ -1,17 +1,17 @@
import { Boxes, Sliders } from 'lucide-react';
import { KubernetesApplicationDeploymentTypes } from '@/kubernetes/models/application/models';
import { BoxSelectorOption } from '@@/BoxSelector';
import { DeploymentType } from '../types';
export function getDeploymentOptions(
supportGlobalDeployment: boolean
): ReadonlyArray<BoxSelectorOption<number>> {
): ReadonlyArray<BoxSelectorOption<DeploymentType>> {
return [
{
id: 'deployment_replicated',
label: 'Replicated',
value: KubernetesApplicationDeploymentTypes.REPLICATED,
value: 'Replicated',
icon: Sliders,
iconType: 'badge',
description: 'Run one or multiple instances of this container',
@ -26,7 +26,7 @@ export function getDeploymentOptions(
label: 'Global',
description:
'Application will be deployed as a DaemonSet with an instance on each node of the cluster',
value: KubernetesApplicationDeploymentTypes.GLOBAL,
value: 'Global',
icon: Boxes,
iconType: 'badge',
},

View file

@ -0,0 +1,73 @@
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { ApplicationFormValues } from '../../types';
import { getAppResourceSummaries, getArticle } from './utils';
import { Summary } from './types';
type Props = {
formValues: ApplicationFormValues;
oldFormValues: ApplicationFormValues;
};
export function ApplicationSummarySection({
formValues,
oldFormValues,
}: Props) {
// extract cpu and memory requests & limits for pod
const limits = {
cpu: formValues.CpuLimit,
memory: formValues.MemoryLimit,
};
const appResourceSummaries = getAppResourceSummaries(
formValues,
oldFormValues
);
if (!appResourceSummaries || appResourceSummaries?.length === 0) {
return null;
}
return (
<FormSection title="Summary" isFoldable defaultFolded={false}>
<TextTip color="blue">
Portainer will execute the following Kubernetes actions.
</TextTip>
<ul className="w-full small text-muted ml-5">
{appResourceSummaries.map((summary) => (
<SummaryItem key={JSON.stringify(summary)} summary={summary} />
))}
{!!limits.memory && (
<li>
Set the memory resources limits and requests to{' '}
<code>{limits.memory}M</code>
</li>
)}
{!!limits.cpu && (
<li>
Set the CPU resources limits and requests to{' '}
<code>{limits.cpu}</code>
</li>
)}
</ul>
</FormSection>
);
}
function SummaryItem({ summary }: { summary: Summary }) {
return (
<li>
{`${summary.action} ${getArticle(summary.kind, summary.action)} `}
<span className="bold">{summary.kind}</span>
{' named '}
<code>{summary.name}</code>
{!!summary.type && (
<span>
{' of type '}
<code>{summary.type}</code>
</span>
)}
</li>
);
}

View file

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

View file

@ -0,0 +1,21 @@
import { AppKind } from '../../types';
export type KubernetesResourceAction = 'Create' | 'Update' | 'Delete';
export type KubernetesResourceType =
| AppKind
| 'Namespace'
| 'ResourceQuota'
| 'ConfigMap'
| 'Secret'
| 'PersistentVolumeClaim'
| 'Service'
| 'Ingress'
| 'HorizontalPodAutoscaler';
export type Summary = {
action: KubernetesResourceAction;
kind: KubernetesResourceType;
name: string;
type?: string;
};

View file

@ -0,0 +1,517 @@
import { ApplicationFormValues } from '../../types';
import { Summary } from './types';
import { getAppResourceSummaries } from './utils';
const complicatedStatefulSet: ApplicationFormValues = {
ApplicationType: 'StatefulSet',
ResourcePool: {
Namespace: {
Id: '9ef75267-3cf4-46f6-879a-5baeceb5c477',
Name: 'default',
CreationDate: '2023-08-30T18:55:34Z',
Status: 'Active',
Yaml: '',
IsSystem: false,
Annotations: [],
},
Ingresses: [],
Yaml: '',
$$hashKey: 'object:702',
},
Name: 'my-app',
StackName: '',
ApplicationOwner: '',
ImageModel: {
UseRegistry: true,
Registry: {
Id: 0,
Type: 0,
Name: 'Docker Hub (anonymous)',
URL: 'docker.io',
},
Image: 'caddy',
},
Note: '',
MemoryLimit: 512,
CpuLimit: 0.5,
DeploymentType: 'Replicated',
ReplicaCount: 1,
AutoScaler: {
isUsed: true,
minReplicas: 1,
maxReplicas: 3,
targetCpuUtilizationPercentage: 50,
},
Containers: [],
Services: [
{
Headless: false,
Namespace: '',
Name: 'my-app',
StackName: '',
Ports: [
{
port: 80,
targetPort: 80,
name: '',
protocol: 'TCP',
serviceName: 'my-app',
ingressPaths: [
{
Host: '127.0.0.1.nip.io',
IngressName: 'default-ingress-3',
Path: '/test',
},
],
},
],
Type: 'ClusterIP',
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: false,
},
{
Headless: false,
Namespace: '',
Name: 'my-app-2',
StackName: '',
Ports: [
{
port: 80,
targetPort: 80,
name: '',
protocol: 'TCP',
nodePort: 30080,
serviceName: 'my-app-2',
},
],
Type: 'NodePort',
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: false,
},
{
Headless: false,
Namespace: '',
Name: 'my-app-3',
StackName: '',
Ports: [
{
port: 80,
targetPort: 80,
name: '',
protocol: 'TCP',
serviceName: 'my-app-3',
},
],
Type: 'LoadBalancer',
ClusterIP: '',
ApplicationName: '',
ApplicationOwner: '',
Note: '',
Ingress: false,
},
],
EnvironmentVariables: [],
DataAccessPolicy: 'Isolated',
PersistedFolders: [
{
persistentVolumeClaimName: 'my-app-6be07c40-de3a-4775-a29b-19a60890052e',
containerPath: 'test',
size: '1',
sizeUnit: 'GB',
storageClass: {
Name: 'local-path',
AccessModes: ['RWO', 'RWX'],
Provisioner: 'rancher.io/local-path',
AllowVolumeExpansion: true,
},
useNewVolume: true,
needsDeletion: false,
},
],
ConfigMaps: [],
Secrets: [],
PlacementType: 'preferred',
Placements: [],
Annotations: [],
};
const complicatedStatefulSetNoServices: ApplicationFormValues = {
ApplicationType: 'StatefulSet',
ResourcePool: {
Namespace: {
Id: '9ef75267-3cf4-46f6-879a-5baeceb5c477',
Name: 'default',
CreationDate: '2023-08-30T18:55:34Z',
Status: 'Active',
Yaml: '',
IsSystem: false,
Annotations: [],
},
Ingresses: [],
Yaml: '',
$$hashKey: 'object:129',
},
Name: 'my-app',
StackName: 'my-app',
ApplicationOwner: 'admin',
ImageModel: {
UseRegistry: true,
Registry: {
Id: 0,
Type: 0,
Name: 'Docker Hub (anonymous)',
URL: 'docker.io',
},
Image: 'caddy:latest',
},
Note: '',
MemoryLimit: 512,
CpuLimit: 0.5,
DeploymentType: 'Replicated',
ReplicaCount: 1,
AutoScaler: {
minReplicas: 1,
maxReplicas: 3,
targetCpuUtilizationPercentage: 50,
isUsed: true,
},
Containers: [
{
Type: 2,
PodName: 'my-app-0',
Name: 'my-app',
Image: 'caddy:latest',
ImagePullPolicy: 'Always',
Status: 'Terminated',
Limits: {
cpu: '500m',
memory: '512M',
},
Requests: {
cpu: '500m',
memory: '512M',
},
VolumeMounts: [
{
name: 'test-6be07c40-de3a-4775-a29b-19a60890052e-test-0',
mountPath: '/test',
},
{
name: 'kube-api-access-n4vht',
readOnly: true,
mountPath: '/var/run/secrets/kubernetes.io/serviceaccount',
},
],
ConfigurationVolumes: [],
PersistedFolders: [
{
MountPath: '/test',
persistentVolumeClaimName:
'test-6be07c40-de3a-4775-a29b-19a60890052e-test-0',
HostPath: '',
},
],
},
],
Services: [],
EnvironmentVariables: [],
DataAccessPolicy: 'Isolated',
PersistedFolders: [
{
persistentVolumeClaimName:
'test-6be07c40-de3a-4775-a29b-19a60890052e-test-0',
needsDeletion: false,
containerPath: '/test',
size: '1',
sizeUnit: 'GB',
storageClass: {
Name: 'local-path',
AccessModes: ['RWO', 'RWX'],
Provisioner: 'rancher.io/local-path',
AllowVolumeExpansion: true,
},
useNewVolume: true,
},
],
ConfigMaps: [],
Secrets: [],
PlacementType: 'preferred',
Placements: [],
Annotations: [],
};
const createComplicatedStatefulSetSummaries: Array<Summary> = [
{
action: 'Create',
kind: 'StatefulSet',
name: 'my-app',
},
{
action: 'Create',
kind: 'Service',
name: 'my-app',
type: 'ClusterIP',
},
{
action: 'Create',
kind: 'Service',
name: 'my-app-2',
type: 'NodePort',
},
{
action: 'Create',
kind: 'Service',
name: 'my-app-3',
type: 'LoadBalancer',
},
{
action: 'Create',
kind: 'Service',
name: 'headless-my-app',
type: 'ClusterIP',
},
{
action: 'Update',
kind: 'Ingress',
name: 'default-ingress-3',
},
{
action: 'Create',
kind: 'HorizontalPodAutoscaler',
name: 'my-app',
},
];
const simpleDaemonset: ApplicationFormValues = {
ApplicationType: 'DaemonSet',
ResourcePool: {
Namespace: {
Id: '49acd824-0ee4-46d1-b1e2-3d36a64ce7e4',
Name: 'default',
CreationDate: '2023-12-19T06:40:12Z',
Status: 'Active',
Yaml: '',
IsSystem: false,
Annotations: [],
},
Ingresses: [],
Yaml: '',
$$hashKey: 'object:418',
},
Name: 'my-app',
StackName: '',
ApplicationOwner: '',
ImageModel: {
UseRegistry: true,
Registry: {
Id: 0,
Type: 0,
Name: 'Docker Hub (anonymous)',
URL: 'docker.io',
},
Image: 'caddy',
},
Note: '',
MemoryLimit: 0,
CpuLimit: 0,
DeploymentType: 'Global',
ReplicaCount: 1,
Containers: [],
DataAccessPolicy: 'Shared',
PersistedFolders: [
{
persistentVolumeClaimName: 'my-app-7c114420-a5d0-491c-8bd6-ec70c3d380be',
containerPath: '/test',
size: '1',
sizeUnit: 'GB',
storageClass: {
Name: 'oci',
AccessModes: ['RWO', 'RWX'],
Provisioner: 'oracle.com/oci',
AllowVolumeExpansion: true,
},
useNewVolume: true,
needsDeletion: false,
},
],
PlacementType: 'preferred',
};
const createSimpleDaemonsetSummaries: Array<Summary> = [
{
action: 'Create',
kind: 'DaemonSet',
name: 'my-app',
},
{
action: 'Create',
kind: 'PersistentVolumeClaim',
name: 'my-app-7c114420-a5d0-491c-8bd6-ec70c3d380be',
},
];
const simpleDeployment: ApplicationFormValues = {
ApplicationType: 'Deployment',
ResourcePool: {
Namespace: {
Id: '49acd824-0ee4-46d1-b1e2-3d36a64ce7e4',
Name: 'default',
CreationDate: '2023-12-19T06:40:12Z',
Status: 'Active',
Yaml: '',
IsSystem: false,
Annotations: [],
},
Ingresses: [],
Yaml: '',
$$hashKey: 'object:582',
},
Name: 'my-app',
StackName: '',
ApplicationOwner: '',
ImageModel: {
UseRegistry: true,
Registry: {
Id: 0,
Type: 0,
Name: 'Docker Hub (anonymous)',
URL: 'docker.io',
},
Image: 'caddy',
},
Note: '',
MemoryLimit: 512,
CpuLimit: 0.5,
DeploymentType: 'Replicated',
ReplicaCount: 1,
Containers: [],
DataAccessPolicy: 'Isolated',
PlacementType: 'preferred',
};
const createSimpleDeploymentSummaries: Array<Summary> = [
{
action: 'Create',
kind: 'Deployment',
name: 'my-app',
},
];
describe('getCreateAppSummaries', () => {
const tests: {
oldFormValues?: ApplicationFormValues;
newFormValues: ApplicationFormValues;
expected: Array<Summary>;
title: string;
}[] = [
{
oldFormValues: undefined,
newFormValues: complicatedStatefulSet,
expected: createComplicatedStatefulSetSummaries,
title: 'should return create summaries for a complicated statefulset',
},
{
oldFormValues: undefined,
newFormValues: simpleDaemonset,
expected: createSimpleDaemonsetSummaries,
title: 'should return create summaries for a simple daemonset',
},
{
oldFormValues: undefined,
newFormValues: simpleDeployment,
expected: createSimpleDeploymentSummaries,
title: 'should return create summaries for a simple deployment',
},
];
tests.forEach((test) => {
// eslint-disable-next-line jest/valid-title
it(test.title, () => {
expect(
getAppResourceSummaries(test.newFormValues, test.oldFormValues)
).toEqual(test.expected);
});
});
});
const updateComplicatedStatefulSetSummaries: Array<Summary> = [
{
action: 'Update',
kind: 'StatefulSet',
name: 'my-app',
},
{
action: 'Delete',
kind: 'Service',
name: 'my-app',
type: 'ClusterIP',
},
{
action: 'Delete',
kind: 'Service',
name: 'my-app-2',
type: 'NodePort',
},
{
action: 'Delete',
kind: 'Service',
name: 'my-app-3',
type: 'LoadBalancer',
},
];
const updateDeploymentToStatefulSetSummaries: Array<Summary> = [
{
action: 'Delete',
kind: 'Deployment',
name: 'my-app',
},
{
action: 'Create',
kind: 'StatefulSet',
name: 'my-app',
},
{
action: 'Create',
kind: 'HorizontalPodAutoscaler',
name: 'my-app',
},
];
describe('getUpdateAppSummaries', () => {
const tests: {
oldFormValues: ApplicationFormValues;
newFormValues: ApplicationFormValues;
expected: Array<Summary>;
title: string;
}[] = [
{
oldFormValues: complicatedStatefulSet,
newFormValues: complicatedStatefulSetNoServices,
expected: updateComplicatedStatefulSetSummaries,
title:
'should return update summaries for removing services from statefulset',
},
{
oldFormValues: simpleDeployment,
newFormValues: complicatedStatefulSetNoServices,
expected: updateDeploymentToStatefulSetSummaries,
title:
'should return update summaries for changing deployment to statefulset',
},
];
tests.forEach((test) => {
// eslint-disable-next-line jest/valid-title
it(test.title, () => {
expect(
getAppResourceSummaries(test.newFormValues, test.oldFormValues)
).toEqual(test.expected);
});
});
});

View file

@ -0,0 +1,362 @@
import { Ingress } from '@/react/kubernetes/ingresses/types';
import { ServiceFormValues } from '../../CreateView/application-services/types';
import { ApplicationFormValues } from '../../types';
import {
generateNewIngressesFromFormPaths,
getServicePatchPayload,
} from '../../CreateView/application-services/utils';
import {
KubernetesResourceType,
KubernetesResourceAction,
Summary,
} from './types';
export function getArticle(
resourceType: KubernetesResourceType,
resourceAction: KubernetesResourceAction
) {
if (resourceAction === 'Delete' || resourceAction === 'Update') {
return 'the';
}
if (resourceAction === 'Create' && resourceType === 'Ingress') {
return 'an';
}
return 'a';
}
/**
* generateResourceSummaryList maps formValues to create and update summaries
*/
export function getAppResourceSummaries(
newFormValues: ApplicationFormValues,
oldFormValues?: ApplicationFormValues
): Array<Summary> {
if (!oldFormValues) {
return getCreatedApplicationResourcesNew(newFormValues);
}
return getUpdatedApplicationResources(newFormValues, oldFormValues);
}
function getCreatedApplicationResourcesNew(
formValues: ApplicationFormValues
): Array<Summary> {
// app summary
const appSummary: Summary = {
action: 'Create',
kind: formValues.ApplicationType,
name: formValues.Name,
};
// service summaries
const serviceFormSummaries: Array<Summary> =
formValues.Services?.map((service) => ({
action: 'Create',
kind: 'Service',
name: service.Name || '',
type: service.Type,
})) || [];
// statefulsets require a headless service (https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#limitations)
// create a headless service summary if the application is a statefulset
const headlessSummary: Array<Summary> =
formValues.ApplicationType === 'StatefulSet'
? [
{
action: 'Create',
kind: 'Service',
name: `headless-${formValues.Name}`,
type: 'ClusterIP',
},
]
: [];
const serviceSummaries = [...serviceFormSummaries, ...headlessSummary];
// ingress summaries
const ingressesSummaries: Array<Summary> =
formValues.Services?.flatMap((service) => {
// a single service port can have multiple ingress paths (and even use different ingresses)
const servicePathsIngressNames = service.Ports.flatMap(
(port) => port.ingressPaths?.map((path) => path.IngressName) || []
);
const uniqueIngressNames = [...new Set(servicePathsIngressNames)];
return uniqueIngressNames.map((ingressName) => ({
action: 'Update',
kind: 'Ingress',
name: ingressName || '',
}));
}) || [];
// persistent volume claim (pvc) summaries
const pvcSummaries: Array<Summary> =
// apps with a isolated data access policy are statefulsets.
// statefulset pvcs are defined in spec.volumeClaimTemplates.
// https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#stable-storage
formValues.DataAccessPolicy === 'Shared'
? formValues.PersistedFolders?.map((volume) => ({
action: 'Create',
kind: 'PersistentVolumeClaim',
name:
volume.existingVolume?.PersistentVolumeClaim.Name ||
volume.persistentVolumeClaimName ||
'',
})) || []
: [];
// horizontal pod autoscaler summaries
const hpaSummary: Array<Summary> =
formValues.AutoScaler?.isUsed === true &&
formValues.DeploymentType !== 'Global'
? [
{
action: 'Create',
kind: 'HorizontalPodAutoscaler',
name: formValues.Name,
},
]
: [];
return [
appSummary,
...serviceSummaries,
...ingressesSummaries,
...pvcSummaries,
...hpaSummary,
];
}
function getUpdatedApplicationResources(
newFormValues: ApplicationFormValues,
oldFormValues: ApplicationFormValues
) {
// app summaries
const updateAppSummaries: Array<Summary> =
oldFormValues.ApplicationType !== newFormValues.ApplicationType
? [
{
action: 'Delete',
kind: oldFormValues.ApplicationType,
name: oldFormValues.Name,
},
{
action: 'Create',
kind: newFormValues.ApplicationType,
name: newFormValues.Name,
},
]
: [
{
action: 'Update',
kind: newFormValues.ApplicationType,
name: newFormValues.Name,
},
];
// service summaries
const serviceSummaries: Array<Summary> = getServiceUpdateResourceSummary(
oldFormValues.Services,
newFormValues.Services
);
// ingress summaries
const oldServicePorts = oldFormValues.Services?.flatMap(
(service) => service.Ports
);
const oldIngresses = generateNewIngressesFromFormPaths(
oldFormValues.OriginalIngresses,
oldServicePorts,
oldServicePorts
);
const newServicePorts = newFormValues.Services?.flatMap(
(service) => service.Ports
);
const newIngresses = generateNewIngressesFromFormPaths(
newFormValues.OriginalIngresses,
newServicePorts,
oldServicePorts
);
const ingressSummaries = getIngressUpdateSummary(oldIngresses, newIngresses);
// persistent volume claim (pvc) summaries
const pvcSummaries: Array<Summary> =
// apps with a isolated data access policy are statefulsets.
// statefulset pvcs are defined in spec.volumeClaimTemplates.
// https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#stable-storage
newFormValues.DataAccessPolicy === 'Shared'
? newFormValues.PersistedFolders?.flatMap((newVolume) => {
const oldVolume = oldFormValues.PersistedFolders?.find(
(oldVolume) =>
oldVolume.persistentVolumeClaimName ===
newVolume.persistentVolumeClaimName
);
if (!oldVolume) {
return [
{
action: 'Create',
kind: 'PersistentVolumeClaim',
name:
newVolume.existingVolume?.PersistentVolumeClaim.Name ||
newVolume.persistentVolumeClaimName ||
'',
},
];
}
// updating a pvc is not supported
return [];
}) || []
: [];
// TODO: horizontal pod autoscaler summaries
const createHPASummary: Array<Summary> =
newFormValues.AutoScaler?.isUsed && !oldFormValues.AutoScaler?.isUsed
? [
{
action: 'Create',
kind: 'HorizontalPodAutoscaler',
name: newFormValues.Name,
},
]
: [];
const deleteHPASummary: Array<Summary> =
!newFormValues.AutoScaler?.isUsed && oldFormValues.AutoScaler?.isUsed
? [
{
action: 'Delete',
kind: 'HorizontalPodAutoscaler',
name: oldFormValues.Name,
},
]
: [];
const isHPAUpdated =
newFormValues.AutoScaler?.isUsed &&
oldFormValues.AutoScaler?.isUsed &&
(newFormValues.AutoScaler?.minReplicas !==
oldFormValues.AutoScaler?.minReplicas ||
newFormValues.AutoScaler?.maxReplicas !==
oldFormValues.AutoScaler?.maxReplicas ||
newFormValues.AutoScaler?.targetCpuUtilizationPercentage !==
oldFormValues.AutoScaler?.targetCpuUtilizationPercentage);
const updateHPASummary: Array<Summary> = isHPAUpdated
? [
{
action: 'Update',
kind: 'HorizontalPodAutoscaler',
name: newFormValues.Name,
},
]
: [];
const hpaSummaries = [
...createHPASummary,
...deleteHPASummary,
...updateHPASummary,
];
return [
...updateAppSummaries,
...serviceSummaries,
...ingressSummaries,
...pvcSummaries,
...hpaSummaries,
];
}
// getServiceUpdateResourceSummary replicates KubernetesServiceService.patch
function getServiceUpdateResourceSummary(
oldServices?: Array<ServiceFormValues>,
newServices?: Array<ServiceFormValues>
): Array<Summary> {
const updateAndCreateSummaries =
newServices?.flatMap<Summary>((newService) => {
const oldServiceMatched = oldServices?.find(
(oldService) => oldService.Name === newService.Name
);
if (oldServiceMatched) {
return getServiceUpdateSummary(oldServiceMatched, newService);
}
return [
{
action: 'Create',
kind: 'Service',
name: newService.Name || '',
type: newService.Type || 'ClusterIP',
},
];
}) || [];
const deleteSummaries =
oldServices?.flatMap<Summary>((oldService) => {
const newServiceMatched = newServices?.find(
(newService) => newService.Name === oldService.Name
);
if (newServiceMatched) {
return [];
}
return [
{
action: 'Delete',
kind: 'Service',
name: oldService.Name || '',
type: oldService.Type || 'ClusterIP',
},
];
}) || [];
return [...updateAndCreateSummaries, ...deleteSummaries];
}
function getServiceUpdateSummary(
oldService: ServiceFormValues,
newService: ServiceFormValues
): Array<Summary> {
const payload = getServicePatchPayload(oldService, newService);
if (payload.length) {
return [
{
action: 'Update',
kind: 'Service',
name: oldService.Name || '',
type: oldService.Type || 'ClusterIP',
},
];
}
return [];
}
export function getIngressUpdateSummary(
oldIngresses: Array<Ingress>,
newIngresses: Array<Ingress>
): Array<Summary> {
const ingressesSummaries = newIngresses.flatMap((newIng) => {
const oldIng = oldIngresses.find((oldIng) => oldIng.Name === newIng.Name);
if (oldIng) {
return getIngressUpdateResourceSummary(oldIng, newIng);
}
return [];
});
return ingressesSummaries;
}
// getIngressUpdateResourceSummary checks if any ingress paths have been changed
function getIngressUpdateResourceSummary(
oldIngress: Ingress,
newIngress: Ingress
): Array<Summary> {
const newIngressPaths = newIngress.Paths?.flatMap((path) => path.Path) || [];
const oldIngressPaths = oldIngress.Paths?.flatMap((path) => path.Path) || [];
const isAnyNewPathMissingOldPath = newIngressPaths.some(
(path) => !oldIngressPaths.includes(path)
);
const isAnyOldPathMissingNewPath = oldIngressPaths.some(
(path) => !newIngressPaths.includes(path)
);
if (isAnyNewPathMissingOldPath || isAnyOldPathMissingNewPath) {
return [
{
action: 'Update',
kind: 'Ingress',
name: oldIngress.Name,
},
];
}
return [];
}

View file

@ -1,4 +1,3 @@
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import clsx from 'clsx';
import { StorageClass } from '@/react/portainer/environments/types';
@ -221,8 +220,7 @@ export function PersistedFolderItem({
function isToggleVolumeTypeVisible() {
return (
!(isEdit && isExistingPersistedFolder()) && // if it's not an edit of an existing persisted folder
applicationValues.ApplicationType !==
KubernetesApplicationTypes.STATEFULSET && // and if it's not a statefulset
applicationValues.ApplicationType !== 'StatefulSet' && // and if it's not a statefulset
applicationValues.Containers.length <= 1 // and if there is only one container);
);
}

View file

@ -1,9 +1,9 @@
import { FormikErrors } from 'formik';
import { useMemo } from 'react';
import uuidv4 from 'uuid/v4';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { StorageClass } from '@/react/portainer/environments/types';
import { KubernetesApplicationTypes } from '@/kubernetes/models/application/models';
import { Option } from '@@/form-components/PortainerSelect';
import { InlineLoader } from '@@/InlineLoader';
@ -43,11 +43,7 @@ export function PersistedFoldersFormSection({
const PVCOptions = usePVCOptions(availableVolumes);
return (
<FormSection
title="Persisted folders"
titleSize="sm"
titleClassName="control-label !text-[0.9em]"
>
<FormSection title="Persisted folders" titleSize="sm">
{storageClasses.length === 0 && (
<TextTip color="blue">
No storage option is available to persist data, contact your
@ -81,17 +77,21 @@ export function PersistedFoldersFormSection({
initialValues={initialValues}
/>
)}
itemBuilder={() => ({
persistentVolumeClaimName:
availableVolumes[0]?.PersistentVolumeClaim.Name || '',
containerPath: '',
size: '',
sizeUnit: 'GB',
storageClass: storageClasses[0],
useNewVolume: true,
existingVolume: undefined,
needsDeletion: false,
})}
itemBuilder={() => {
const newVolumeClaimName = `${applicationValues.Name}-${uuidv4()}`;
return {
persistentVolumeClaimName:
availableVolumes[0]?.PersistentVolumeClaim.Name ||
newVolumeClaimName,
containerPath: '',
size: '',
sizeUnit: 'GB',
storageClass: storageClasses[0],
useNewVolume: true,
existingVolume: undefined,
needsDeletion: false,
};
}}
addLabel="Add persisted folder"
canUndoDelete={isEdit}
/>
@ -100,9 +100,7 @@ export function PersistedFoldersFormSection({
function isDeleteButtonHidden() {
return (
(isEdit &&
applicationValues.ApplicationType ===
KubernetesApplicationTypes.STATEFULSET) ||
(isEdit && applicationValues.ApplicationType === 'StatefulSet') ||
applicationValues.Containers.length >= 1
);
}

View file

@ -35,11 +35,7 @@ export function PlacementFormSection({ values, onChange, errors }: Props) {
return (
<div className="flex flex-col">
<FormSection
title="Placement preferences and constraints"
titleSize="sm"
titleClassName="control-label !text-[0.9em]"
>
<FormSection title="Placement preferences and constraints" titleSize="sm">
{values.placements?.length > 0 && (
<TextTip color="blue">
Deploy this application on nodes that respect <b>ALL</b> of the

View file

@ -7,6 +7,7 @@ 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 appNameLabel = 'io.portainer.kubernetes.application.name';
export const appRevisionAnnotation = 'deployment.kubernetes.io/revision';
@ -30,4 +31,4 @@ export const appKindToDeploymentTypeMap: Record<
StatefulSet: 'Replicated',
DaemonSet: 'Global',
Pod: null,
};
} as const;

View file

@ -11,9 +11,44 @@ import {
import { Pod, PodList } from 'kubernetes-types/core/v1';
import { RawExtension } from 'kubernetes-types/runtime';
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
import { Annotation } from '../annotations/types';
import { Ingress } from '../ingresses/types';
import { AutoScalingFormValues } from './components/AutoScalingFormSection/types';
import { ServiceFormValues } from './CreateView/application-services/types';
import { PersistedFolderFormValue } from './components/PersistedFoldersFormSection/types';
import { ConfigurationFormValues } from './components/ConfigurationsFormSection/types';
import {
Placement,
PlacementType,
} from './components/PlacementFormSection/types';
export type ApplicationFormValues = {
Containers: Array<unknown>;
ApplicationType: number; // KubernetesApplicationTypes
ApplicationType: AppKind;
ResourcePool: unknown;
Name: string;
StackName?: string;
ApplicationOwner?: string;
ImageModel: unknown;
Note?: string;
MemoryLimit?: number;
CpuLimit?: number;
DeploymentType?: DeploymentType;
ReplicaCount?: number;
AutoScaler?: AutoScalingFormValues;
Services?: Array<ServiceFormValues>;
OriginalIngresses?: Array<Ingress>;
EnvironmentVariables?: EnvVarValues;
DataAccessPolicy?: AppDataAccessPolicy;
PersistedFolders?: Array<PersistedFolderFormValue>;
ConfigMaps?: Array<ConfigurationFormValues>;
Secrets?: Array<ConfigurationFormValues>;
PlacementType?: PlacementType;
Placements?: Array<Placement>;
Annotations?: Array<Annotation>;
};
export type Application = Deployment | DaemonSet | StatefulSet | Pod;
@ -30,8 +65,12 @@ export type ApplicationList =
export type AppKind = 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod';
export type AppType = AppKind | 'Helm';
export type DeploymentType = 'Replicated' | 'Global';
export type AppDataAccessPolicy = 'Isolated' | 'Shared';
type Patch = {
op: 'replace' | 'add' | 'remove';
path: string;