mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(app): details widget migration [EE-5352] (#8886)
This commit is contained in:
parent
fdd79cece8
commit
af77e33993
57 changed files with 2046 additions and 1079 deletions
|
@ -0,0 +1,74 @@
|
|||
import { Move } from 'lucide-react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
import { Application } from '../../types';
|
||||
import { useApplicationHorizontalPodAutoscalers } from '../../application.queries';
|
||||
|
||||
type Props = {
|
||||
environmentId: EnvironmentId;
|
||||
namespace: string;
|
||||
appName: string;
|
||||
app?: Application;
|
||||
};
|
||||
|
||||
export function ApplicationAutoScalingTable({
|
||||
environmentId,
|
||||
namespace,
|
||||
appName,
|
||||
app,
|
||||
}: Props) {
|
||||
const { data: appAutoScalar } = useApplicationHorizontalPodAutoscalers(
|
||||
environmentId,
|
||||
namespace,
|
||||
appName,
|
||||
app
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-muted mb-4 flex items-center">
|
||||
<Icon icon={Move} className="!mr-2" />
|
||||
Auto-scaling
|
||||
</div>
|
||||
{!appAutoScalar && (
|
||||
<TextTip color="blue">
|
||||
This application does not have an autoscaling policy defined.
|
||||
</TextTip>
|
||||
)}
|
||||
{appAutoScalar && (
|
||||
<div className="mt-4 w-3/5">
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr className="text-muted">
|
||||
<td className="w-1/3">Minimum instances</td>
|
||||
<td className="w-1/3">Maximum instances</td>
|
||||
<td className="w-1/3">
|
||||
<div className="flex min-w-max items-center gap-1">
|
||||
Target CPU usage
|
||||
<Tooltip message="The autoscaler will ensure enough instances are running to maintain an average CPU usage across all instances." />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-cy="k8sAppDetail-minReplicas">
|
||||
{appAutoScalar.spec?.minReplicas}
|
||||
</td>
|
||||
<td data-cy="k8sAppDetail-maxReplicas">
|
||||
{appAutoScalar.spec?.maxReplicas}
|
||||
</td>
|
||||
<td data-cy="k8sAppDetail-targetCPU">
|
||||
{appAutoScalar.spec?.targetCPUUtilizationPercentage}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
import { Pencil, Plus } from 'lucide-react';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { useStackFile } from '@/react/common/stacks/stack.service';
|
||||
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import {
|
||||
useApplication,
|
||||
useApplicationServices,
|
||||
} from '../../application.queries';
|
||||
import { isSystemNamespace } from '../../../namespaces/utils';
|
||||
import { applicationIsKind, isExternalApplication } from '../../utils';
|
||||
import { appStackIdLabel } from '../../constants';
|
||||
|
||||
import { RestartApplicationButton } from './RestartApplicationButton';
|
||||
import { RedeployApplicationButton } from './RedeployApplicationButton';
|
||||
import { RollbackApplicationButton } from './RollbackApplicationButton';
|
||||
import { ApplicationServicesTable } from './ApplicationServicesTable';
|
||||
import { ApplicationIngressesTable } from './ApplicationIngressesTable';
|
||||
import { ApplicationAutoScalingTable } from './ApplicationAutoScalingTable';
|
||||
import { ApplicationEnvVarsTable } from './ApplicationEnvVarsTable';
|
||||
import { ApplicationVolumeConfigsTable } from './ApplicationVolumeConfigsTable';
|
||||
import { ApplicationPersistentDataTable } from './ApplicationPersistentDataTable';
|
||||
|
||||
export function ApplicationDetailsWidget() {
|
||||
const stateAndParams = useCurrentStateAndParams();
|
||||
const {
|
||||
params: {
|
||||
namespace,
|
||||
name,
|
||||
'resource-type': resourceType,
|
||||
endpointId: environmentId,
|
||||
},
|
||||
} = stateAndParams;
|
||||
|
||||
// get app info
|
||||
const appQuery = useApplication(environmentId, namespace, name, resourceType);
|
||||
const app = appQuery.data;
|
||||
const externalApp = app && isExternalApplication(app);
|
||||
const appStackId = Number(app?.metadata?.labels?.[appStackIdLabel]);
|
||||
const appStackFileQuery = useStackFile(appStackId);
|
||||
const { data: appServices } = useApplicationServices(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
app
|
||||
);
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
{!isSystemNamespace(namespace) && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Authorized authorizations="K8sApplicationDetailsW">
|
||||
<Link to="kubernetes.applications.application.edit">
|
||||
<Button
|
||||
type="button"
|
||||
color="light"
|
||||
size="small"
|
||||
className="hover:decoration-none !ml-0"
|
||||
data-cy="k8sAppDetail-editAppButton"
|
||||
>
|
||||
<Icon icon={Pencil} className="mr-1" />
|
||||
{externalApp
|
||||
? 'Edit external application'
|
||||
: 'Edit this application'}
|
||||
</Button>
|
||||
</Link>
|
||||
</Authorized>
|
||||
{!applicationIsKind<Pod>('Pod', app) && (
|
||||
<>
|
||||
<RestartApplicationButton />
|
||||
<RedeployApplicationButton
|
||||
environmentId={environmentId}
|
||||
namespace={namespace}
|
||||
appName={name}
|
||||
app={app}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!externalApp && (
|
||||
<RollbackApplicationButton
|
||||
environmentId={environmentId}
|
||||
namespace={namespace}
|
||||
appName={name}
|
||||
app={app}
|
||||
/>
|
||||
)}
|
||||
{appStackFileQuery.data && (
|
||||
<Link
|
||||
to="kubernetes.templates.custom.new"
|
||||
params={{
|
||||
fileContent: appStackFileQuery.data.StackFileContent,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
size="small"
|
||||
className="hover:decoration-none !ml-0"
|
||||
data-cy="k8sAppDetail-createCustomTemplateButton"
|
||||
>
|
||||
<Icon icon={Plus} className="mr-1" />
|
||||
Create template from application
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ApplicationServicesTable
|
||||
environmentId={environmentId}
|
||||
appServices={appServices}
|
||||
/>
|
||||
<ApplicationIngressesTable
|
||||
appServices={appServices}
|
||||
environmentId={environmentId}
|
||||
namespace={namespace}
|
||||
/>
|
||||
<ApplicationAutoScalingTable
|
||||
environmentId={environmentId}
|
||||
namespace={namespace}
|
||||
appName={name}
|
||||
app={app}
|
||||
/>
|
||||
<ApplicationEnvVarsTable namespace={namespace} app={app} />
|
||||
<ApplicationVolumeConfigsTable namespace={namespace} app={app} />
|
||||
<ApplicationPersistentDataTable
|
||||
environmentId={environmentId}
|
||||
namespace={namespace}
|
||||
appName={name}
|
||||
app={app}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,172 @@
|
|||
import { EnvVar, Pod } from 'kubernetes-types/core/v1';
|
||||
import { Asterisk, File, Key } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Application } from '../../types';
|
||||
import { applicationIsKind } from '../../utils';
|
||||
|
||||
type Props = {
|
||||
namespace: string;
|
||||
app?: Application;
|
||||
};
|
||||
|
||||
export function ApplicationEnvVarsTable({ namespace, app }: Props) {
|
||||
const appEnvVars = getApplicationEnvironmentVariables(app);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-muted mb-4 mt-6 flex items-center">
|
||||
<Icon icon={File} className="!mr-2" />
|
||||
Configuration
|
||||
</div>
|
||||
{appEnvVars.length === 0 && (
|
||||
<TextTip color="blue">
|
||||
This application is not using any environment variable or
|
||||
configuration.
|
||||
</TextTip>
|
||||
)}
|
||||
{appEnvVars.length > 0 && (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr className="text-muted">
|
||||
<td className="w-1/4">Container</td>
|
||||
<td className="w-1/4">Environment variable</td>
|
||||
<td className="w-1/4">Value</td>
|
||||
<td className="w-1/4">Configuration</td>
|
||||
</tr>
|
||||
{appEnvVars.map((envVar, index) => (
|
||||
<tr key={index}>
|
||||
<td data-cy="k8sAppDetail-containerName">
|
||||
{envVar.containerName}
|
||||
{envVar.isInitContainer && (
|
||||
<span>
|
||||
<Icon icon={Asterisk} className="!ml-1" />
|
||||
{envVar.valueFrom?.fieldRef?.fieldPath} (
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
init container
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td data-cy="k8sAppDetail-envVarName">{envVar.name}</td>
|
||||
<td data-cy="k8sAppDetail-envVarValue">
|
||||
{envVar.value && <span>{envVar.value}</span>}
|
||||
{envVar.valueFrom?.fieldRef?.fieldPath && (
|
||||
<span>
|
||||
<Icon icon={Asterisk} className="!ml-1" />
|
||||
{envVar.valueFrom.fieldRef.fieldPath} (
|
||||
<a
|
||||
href="https://kubernetes.io/docs/tasks/inject-data-application/downward-api-volume-expose-pod-information/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
downward API
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
{envVar.valueFrom?.secretKeyRef?.key && (
|
||||
<span className="flex items-center">
|
||||
<Icon icon={Key} className="!mr-1" />
|
||||
{envVar.valueFrom.secretKeyRef.key}
|
||||
</span>
|
||||
)}
|
||||
{envVar.valueFrom?.configMapKeyRef?.key && (
|
||||
<span className="flex items-center">
|
||||
<Icon icon={Key} className="!mr-1" />
|
||||
{envVar.valueFrom.configMapKeyRef.key}
|
||||
</span>
|
||||
)}
|
||||
{!envVar.value && !envVar.valueFrom && <span>-</span>}
|
||||
</td>
|
||||
<td data-cy="k8sAppDetail-configName">
|
||||
{!envVar.valueFrom?.configMapKeyRef?.name &&
|
||||
!envVar.valueFrom?.secretKeyRef?.name && <span>-</span>}
|
||||
{envVar.valueFrom?.configMapKeyRef && (
|
||||
<span>
|
||||
<Link
|
||||
to="kubernetes.configurations.configuration"
|
||||
params={{
|
||||
name: envVar.valueFrom.configMapKeyRef.name,
|
||||
namespace,
|
||||
}}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Icon icon={File} className="!mr-1" />
|
||||
{envVar.valueFrom.configMapKeyRef.name}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{envVar.valueFrom?.secretKeyRef && (
|
||||
<span>
|
||||
<Link
|
||||
to="kubernetes.configurations.configuration"
|
||||
params={{
|
||||
name: envVar.valueFrom.secretKeyRef.name,
|
||||
namespace,
|
||||
}}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Icon icon={File} className="!mr-1" />
|
||||
{envVar.valueFrom.secretKeyRef.name}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContainerEnvVar extends EnvVar {
|
||||
containerName: string;
|
||||
isInitContainer: boolean;
|
||||
}
|
||||
|
||||
function getApplicationEnvironmentVariables(
|
||||
app?: Application
|
||||
): ContainerEnvVar[] {
|
||||
if (!app) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const podSpec = applicationIsKind<Pod>('Pod', app)
|
||||
? app.spec
|
||||
: app.spec?.template?.spec;
|
||||
const appContainers = podSpec?.containers || [];
|
||||
const appInitContainers = podSpec?.initContainers || [];
|
||||
|
||||
// get all the environment variables for each container
|
||||
const appContainersEnvVars =
|
||||
appContainers?.flatMap(
|
||||
(container) =>
|
||||
container?.env?.map((envVar) => ({
|
||||
...envVar,
|
||||
containerName: container.name,
|
||||
isInitContainer: false,
|
||||
})) || []
|
||||
) || [];
|
||||
const appInitContainersEnvVars =
|
||||
appInitContainers?.flatMap(
|
||||
(container) =>
|
||||
container?.env?.map((envVar) => ({
|
||||
...envVar,
|
||||
containerName: container.name,
|
||||
isInitContainer: true,
|
||||
})) || []
|
||||
) || [];
|
||||
|
||||
return [...appContainersEnvVars, ...appInitContainersEnvVars];
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
import { Service } from 'kubernetes-types/core/v1';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useIngresses } from '@/react/kubernetes/ingresses/queries';
|
||||
import { Ingress } from '@/react/kubernetes/ingresses/types';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
type Props = {
|
||||
environmentId: EnvironmentId;
|
||||
namespace: string;
|
||||
appServices?: Service[];
|
||||
};
|
||||
|
||||
export function ApplicationIngressesTable({
|
||||
environmentId,
|
||||
namespace,
|
||||
appServices,
|
||||
}: Props) {
|
||||
const namespaceIngresses = useIngresses(environmentId, [namespace]);
|
||||
// getIngressPathsForAppServices could be expensive, so memoize it
|
||||
const ingressPathsForAppServices = useMemo(
|
||||
() => getIngressPathsForAppServices(namespaceIngresses.data, appServices),
|
||||
[namespaceIngresses.data, appServices]
|
||||
);
|
||||
|
||||
if (!ingressPathsForAppServices.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="mt-4 table">
|
||||
<tbody>
|
||||
<tr className="text-muted">
|
||||
<td className="w-[15%]">Ingress name</td>
|
||||
<td className="w-[10%]">Service name</td>
|
||||
<td className="w-[10%]">Host</td>
|
||||
<td className="w-[10%]">Port</td>
|
||||
<td className="w-[10%]">Path</td>
|
||||
<td className="w-[15%]">HTTP Route</td>
|
||||
</tr>
|
||||
{ingressPathsForAppServices.map((ingressPath, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<Authorized authorizations="K8sIngressesW">
|
||||
<Link
|
||||
to="kubernetes.ingresses.edit"
|
||||
params={{ name: ingressPath.ingressName, namespace }}
|
||||
>
|
||||
{ingressPath.ingressName}
|
||||
</Link>
|
||||
</Authorized>
|
||||
</td>
|
||||
<td>{ingressPath.serviceName}</td>
|
||||
<td>{ingressPath.host}</td>
|
||||
<td>{ingressPath.port}</td>
|
||||
<td>{ingressPath.path}</td>
|
||||
<td>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`${ingressPath.secure ? 'https' : 'http'}://${
|
||||
ingressPath.host
|
||||
}${ingressPath.path}`}
|
||||
>
|
||||
{ingressPath.host}
|
||||
{ingressPath.path}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
type IngressPath = {
|
||||
ingressName: string;
|
||||
serviceName: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
host: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
function getIngressPathsForAppServices(
|
||||
ingresses?: Ingress[],
|
||||
services?: Service[]
|
||||
): IngressPath[] {
|
||||
if (!ingresses || !services) {
|
||||
return [];
|
||||
}
|
||||
const matchingIngressesPaths = ingresses.flatMap((ingress) => {
|
||||
// for each ingress get an array of ingress paths that match the app services
|
||||
const matchingIngressPaths = ingress.Paths.filter((path) =>
|
||||
services?.some((service) => {
|
||||
const servicePorts = service.spec?.ports?.map((port) => port.port);
|
||||
// include the ingress if the ingress path has a matching service name and port
|
||||
return (
|
||||
path.ServiceName === service.metadata?.name &&
|
||||
servicePorts?.includes(path.Port)
|
||||
);
|
||||
})
|
||||
).map((path) => {
|
||||
const secure =
|
||||
(ingress.TLS &&
|
||||
ingress.TLS.filter(
|
||||
(tls) => tls.Hosts && tls.Hosts.includes(path.Host)
|
||||
).length > 0) ??
|
||||
false;
|
||||
return {
|
||||
ingressName: ingress.Name,
|
||||
serviceName: path.ServiceName,
|
||||
port: path.Port,
|
||||
secure,
|
||||
host: path.Host,
|
||||
path: path.Path,
|
||||
};
|
||||
});
|
||||
return matchingIngressPaths;
|
||||
});
|
||||
return matchingIngressesPaths;
|
||||
}
|
|
@ -0,0 +1,268 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Asterisk, Box, Boxes, Database } from 'lucide-react';
|
||||
import { Container, Pod, Volume } from 'kubernetes-types/core/v1';
|
||||
import { StatefulSet } from 'kubernetes-types/apps/v1';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Application } from '../../types';
|
||||
import { applicationIsKind } from '../../utils';
|
||||
import { useApplicationPods } from '../../application.queries';
|
||||
|
||||
type Props = {
|
||||
environmentId: EnvironmentId;
|
||||
namespace: string;
|
||||
appName: string;
|
||||
app?: Application;
|
||||
};
|
||||
|
||||
export function ApplicationPersistentDataTable({
|
||||
namespace,
|
||||
app,
|
||||
environmentId,
|
||||
appName,
|
||||
}: Props) {
|
||||
const { data: pods } = useApplicationPods(
|
||||
environmentId,
|
||||
namespace,
|
||||
appName,
|
||||
app
|
||||
);
|
||||
const persistedFolders = useMemo(
|
||||
() => getPersistedFolders(app, pods),
|
||||
[app, pods]
|
||||
);
|
||||
const dataAccessPolicy = getDataAccessPolicy(app);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-muted mb-4 mt-6 flex items-center">
|
||||
<Icon icon={Database} className="!mr-2 !shrink-0" />
|
||||
Data persistence
|
||||
</div>
|
||||
{!persistedFolders.length && (
|
||||
<TextTip color="blue">
|
||||
This application has no persisted folders.
|
||||
</TextTip>
|
||||
)}
|
||||
{persistedFolders.length > 0 && (
|
||||
<>
|
||||
<div className="small text-muted vertical-center mb-4">
|
||||
Data access policy:
|
||||
{dataAccessPolicy === 'isolated' && (
|
||||
<>
|
||||
<Icon icon={Boxes} />
|
||||
Isolated
|
||||
<Tooltip message="All the instances of this application are using their own data." />
|
||||
</>
|
||||
)}
|
||||
{dataAccessPolicy === 'shared' && (
|
||||
<>
|
||||
<Icon icon={Box} />
|
||||
Shared
|
||||
<Tooltip message="All the instances of this application are sharing the same data." />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{dataAccessPolicy === 'isolated' && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr className="text-muted">
|
||||
<td className="w-1/4">Container name</td>
|
||||
<td className="w-1/4">Pod name</td>
|
||||
<td className="w-1/4">Persisted folder</td>
|
||||
<td className="w-1/4">Persistence</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{persistedFolders.map((persistedFolder, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
{persistedFolder.volumeMount.container.name}
|
||||
{persistedFolder.isContainerInit && (
|
||||
<span>
|
||||
<Icon icon={Asterisk} className="!mr-1" />(
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
init container
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{persistedFolder.volumeMount?.pod?.metadata?.name}</td>
|
||||
<td>{persistedFolder.volumeMount.mountPath}</td>
|
||||
<td>
|
||||
{persistedFolder.volume.persistentVolumeClaim && (
|
||||
<Link
|
||||
className="hyperlink flex items-center"
|
||||
to="kubernetes.volumes.volume"
|
||||
params={{
|
||||
name: `${persistedFolder.volume.persistentVolumeClaim.claimName}-${persistedFolder.volumeMount?.pod?.metadata?.name}`,
|
||||
namespace,
|
||||
}}
|
||||
>
|
||||
<Icon icon={Database} className="!mr-1 shrink-0" />
|
||||
{`${persistedFolder.volume.persistentVolumeClaim.claimName}-${persistedFolder.volumeMount?.pod?.metadata?.name}`}
|
||||
</Link>
|
||||
)}
|
||||
{persistedFolder.volume.hostPath &&
|
||||
`${persistedFolder.volume.hostPath.path} on host filesystem`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{dataAccessPolicy === 'shared' && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr className="text-muted">
|
||||
<td className="w-1/3">Persisted folder</td>
|
||||
<td className="w-2/3">Persistence</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="border-t-0">
|
||||
{persistedFolders.map((persistedFolder, index) => (
|
||||
<tr key={index}>
|
||||
<td data-cy="k8sAppDetail-volMountPath">
|
||||
{persistedFolder.volumeMount.mountPath}
|
||||
</td>
|
||||
<td>
|
||||
{persistedFolder.volume.persistentVolumeClaim && (
|
||||
<Link
|
||||
className="hyperlink flex items-center"
|
||||
to="kubernetes.volumes.volume"
|
||||
params={{
|
||||
name: persistedFolder.volume.persistentVolumeClaim
|
||||
.claimName,
|
||||
namespace,
|
||||
}}
|
||||
>
|
||||
<Icon icon={Database} className="!mr-1 shrink-0" />
|
||||
{
|
||||
persistedFolder.volume.persistentVolumeClaim
|
||||
.claimName
|
||||
}
|
||||
</Link>
|
||||
)}
|
||||
{persistedFolder.volume.hostPath &&
|
||||
`${persistedFolder.volume.hostPath.path} on host filesystem`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getDataAccessPolicy(app?: Application) {
|
||||
if (!app || applicationIsKind<Pod>('Pod', app)) {
|
||||
return 'none';
|
||||
}
|
||||
if (applicationIsKind<StatefulSet>('StatefulSet', app)) {
|
||||
return 'isolated';
|
||||
}
|
||||
return 'shared';
|
||||
}
|
||||
|
||||
function getPodsMatchingContainer(pods: Pod[], container: Container) {
|
||||
const matchingPods = pods.filter((pod) => {
|
||||
const podContainers = pod.spec?.containers || [];
|
||||
const podInitContainers = pod.spec?.initContainers || [];
|
||||
const podAllContainers = [...podContainers, ...podInitContainers];
|
||||
return podAllContainers.some(
|
||||
(podContainer) =>
|
||||
podContainer.name === container.name &&
|
||||
podContainer.image === container.image
|
||||
);
|
||||
});
|
||||
return matchingPods;
|
||||
}
|
||||
|
||||
function getPersistedFolders(app?: Application, pods?: Pod[]) {
|
||||
if (!app || !pods) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const podSpec = applicationIsKind<Pod>('Pod', app)
|
||||
? app.spec
|
||||
: app.spec?.template?.spec;
|
||||
|
||||
const appVolumes = podSpec?.volumes || [];
|
||||
const appVolumeClaimVolumes = getVolumeClaimTemplates(app, appVolumes);
|
||||
const appAllVolumes = [...appVolumes, ...appVolumeClaimVolumes];
|
||||
|
||||
const appContainers = podSpec?.containers || [];
|
||||
const appInitContainers = podSpec?.initContainers || [];
|
||||
const appAllContainers = [...appContainers, ...appInitContainers];
|
||||
|
||||
// for each volume, find the volumeMounts that match it
|
||||
const persistedFolders = appAllVolumes.flatMap((volume) => {
|
||||
if (volume.persistentVolumeClaim || volume.hostPath) {
|
||||
const volumeMounts = appAllContainers.flatMap((container) => {
|
||||
const matchingPods = getPodsMatchingContainer(pods, container);
|
||||
return (
|
||||
container.volumeMounts?.flatMap(
|
||||
(containerVolumeMount) =>
|
||||
matchingPods.map((pod) => ({
|
||||
...containerVolumeMount,
|
||||
container,
|
||||
pod,
|
||||
})) || []
|
||||
) || []
|
||||
);
|
||||
});
|
||||
const uniqueMatchingVolumeMounts = volumeMounts.filter(
|
||||
(volumeMount, index, self) =>
|
||||
self.indexOf(volumeMount) === index && // remove volumeMounts with duplicate names
|
||||
volumeMount.name === volume.name // remove volumeMounts that don't match the volume
|
||||
);
|
||||
return uniqueMatchingVolumeMounts.map((volumeMount) => ({
|
||||
volume,
|
||||
volumeMount,
|
||||
isContainerInit: appInitContainers.some(
|
||||
(container) => container.name === volumeMount.container.name
|
||||
),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
return persistedFolders;
|
||||
}
|
||||
|
||||
function getVolumeClaimTemplates(app: Application, volumes: Volume[]) {
|
||||
if (
|
||||
applicationIsKind<StatefulSet>('StatefulSet', app) &&
|
||||
app.spec?.volumeClaimTemplates
|
||||
) {
|
||||
const volumeClaimTemplates: Volume[] = app.spec.volumeClaimTemplates.map(
|
||||
(vc) => ({
|
||||
name: vc.metadata?.name || '',
|
||||
persistentVolumeClaim: { claimName: vc.metadata?.name || '' },
|
||||
})
|
||||
);
|
||||
const newPVC = volumeClaimTemplates.filter(
|
||||
(vc) =>
|
||||
!volumes.find(
|
||||
(v) =>
|
||||
v.persistentVolumeClaim?.claimName ===
|
||||
vc.persistentVolumeClaim?.claimName
|
||||
)
|
||||
);
|
||||
return newPVC;
|
||||
}
|
||||
return [];
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
import { Service } from 'kubernetes-types/core/v1';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useEnvironment } from '@/react/portainer/environments/queries';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
type Props = {
|
||||
environmentId: EnvironmentId;
|
||||
appServices?: Service[];
|
||||
};
|
||||
|
||||
export function ApplicationServicesTable({
|
||||
environmentId,
|
||||
appServices,
|
||||
}: Props) {
|
||||
const { data: environment } = useEnvironment(environmentId);
|
||||
return (
|
||||
<>
|
||||
<div className="text-muted mb-4 flex items-center">
|
||||
<Icon icon={ExternalLink} className="!mr-2" />
|
||||
Accessing the application
|
||||
</div>
|
||||
{appServices && appServices.length === 0 && (
|
||||
<TextTip color="blue" className="mb-4">
|
||||
This application is not exposing any port.
|
||||
</TextTip>
|
||||
)}
|
||||
{appServices && appServices.length > 0 && (
|
||||
<>
|
||||
<TextTip color="blue" className="mb-4">
|
||||
This application is exposed through service(s) as below:
|
||||
</TextTip>
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr className="text-muted">
|
||||
<td className="w-[15%]">Service name</td>
|
||||
<td className="w-[10%]">Type</td>
|
||||
<td className="w-[10%]">Cluster IP</td>
|
||||
<td className="w-[10%]">External IP</td>
|
||||
<td className="w-[10%]">Container port</td>
|
||||
<td className="w-[15%]">Service port(s)</td>
|
||||
</tr>
|
||||
{appServices.map((service) => (
|
||||
<tr key={service.metadata?.name}>
|
||||
<td>{service.metadata?.name}</td>
|
||||
<td>{service.spec?.type}</td>
|
||||
<td>{service.spec?.clusterIP}</td>
|
||||
{service.spec?.type === 'LoadBalancer' && (
|
||||
<td>
|
||||
{service.status?.loadBalancer?.ingress?.[0] &&
|
||||
service.spec?.ports?.[0] && (
|
||||
<a
|
||||
className="vertical-center hyperlink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`http://${service.status.loadBalancer.ingress[0].ip}:${service.spec.ports[0].port}`}
|
||||
>
|
||||
<Icon icon={ExternalLink} className="!mr-1" />
|
||||
<span data-cy="k8sAppDetail-containerPort">
|
||||
Access
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{!service.status?.loadBalancer?.ingress && (
|
||||
<div>
|
||||
{service.spec.externalIPs?.[0]
|
||||
? service.spec.externalIPs[0]
|
||||
: 'pending...'}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{service.spec?.type !== 'LoadBalancer' && (
|
||||
<td>
|
||||
{service.spec?.externalIPs?.[0]
|
||||
? service.spec.externalIPs[0]
|
||||
: '-'}
|
||||
</td>
|
||||
)}
|
||||
<td data-cy="k8sAppDetail-containerPort">
|
||||
{service.spec?.ports?.map((port) => (
|
||||
<div key={port.port}>{port.targetPort}</div>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
{service.spec?.ports?.map((port) => (
|
||||
<div key={port.port}>
|
||||
{environment?.PublicURL && port.nodePort && (
|
||||
<a
|
||||
className="vertical-center hyperlink"
|
||||
href={`http://${environment?.PublicURL}:${port.nodePort}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={ExternalLink} className="!mr-1" />
|
||||
<span data-cy="k8sAppDetail-containerPort">
|
||||
{port.port}
|
||||
</span>
|
||||
<span>{port.nodePort ? ' : ' : ''}</span>
|
||||
<span data-cy="k8sAppDetail-nodePort">
|
||||
{port.nodePort}/{port.protocol}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{!environment?.PublicURL && (
|
||||
<div>
|
||||
<span data-cy="k8sAppDetail-servicePort">
|
||||
{port.port}
|
||||
</span>
|
||||
<span>{port.nodePort ? ' : ' : ''}</span>
|
||||
<span data-cy="k8sAppDetail-nodePort">
|
||||
{port.nodePort}/{port.protocol}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
import { KeyToPath, Pod } from 'kubernetes-types/core/v1';
|
||||
import { Asterisk, Plus } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Application } from '../../types';
|
||||
import { applicationIsKind } from '../../utils';
|
||||
|
||||
type Props = {
|
||||
namespace: string;
|
||||
app?: Application;
|
||||
};
|
||||
|
||||
export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
|
||||
const containerVolumeConfigs = getApplicationVolumeConfigs(app);
|
||||
|
||||
if (containerVolumeConfigs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr className="text-muted">
|
||||
<td className="w-1/4">Container</td>
|
||||
<td className="w-1/4">Configuration path</td>
|
||||
<td className="w-1/4">Value</td>
|
||||
<td className="w-1/4">Configuration</td>
|
||||
</tr>
|
||||
{containerVolumeConfigs.map(
|
||||
(
|
||||
{
|
||||
containerVolumeMount,
|
||||
isInitContainer,
|
||||
containerName,
|
||||
item,
|
||||
volumeConfigName,
|
||||
},
|
||||
index
|
||||
) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
{containerName}
|
||||
{isInitContainer && (
|
||||
<span>
|
||||
<Icon icon={Asterisk} />(
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
init container
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{item.path
|
||||
? `${containerVolumeMount?.mountPath}/${item.path}`
|
||||
: `${containerVolumeMount?.mountPath}`}
|
||||
</td>
|
||||
<td>
|
||||
{item.key && (
|
||||
<div className="flex items-center">
|
||||
<Icon icon={Plus} className="!mr-1" />
|
||||
{item.key}
|
||||
</div>
|
||||
)}
|
||||
{!item.key && '-'}
|
||||
</td>
|
||||
<td>
|
||||
{volumeConfigName && (
|
||||
<Link
|
||||
className="flex items-center"
|
||||
to="kubernetes.configurations.configuration"
|
||||
params={{ name: volumeConfigName, namespace }}
|
||||
>
|
||||
<Icon icon={Plus} className="!mr-1" />
|
||||
{volumeConfigName}
|
||||
</Link>
|
||||
)}
|
||||
{!volumeConfigName && '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// getApplicationVolumeConfigs returns a list of volume configs / secrets for each container and each item within the matching volume
|
||||
function getApplicationVolumeConfigs(app?: Application) {
|
||||
if (!app) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const podSpec = applicationIsKind<Pod>('Pod', app)
|
||||
? app.spec
|
||||
: app.spec?.template?.spec;
|
||||
const appContainers = podSpec?.containers || [];
|
||||
const appInitContainers = podSpec?.initContainers || [];
|
||||
const appVolumes = podSpec?.volumes || [];
|
||||
const allContainers = [...appContainers, ...appInitContainers];
|
||||
|
||||
const appVolumeConfigs = allContainers.flatMap((container) => {
|
||||
// for each container, get the volume mount paths
|
||||
const matchingVolumes = appVolumes
|
||||
// filter app volumes by config map or secret
|
||||
.filter((volume) => volume.configMap || volume.secret)
|
||||
.flatMap((volume) => {
|
||||
// flatten by volume items if there are any
|
||||
const volConfigMapItems =
|
||||
volume.configMap?.items || volume.secret?.items || [];
|
||||
const volumeConfigName =
|
||||
volume.configMap?.name || volume.secret?.secretName;
|
||||
const containerVolumeMount = container.volumeMounts?.find(
|
||||
(volumeMount) => volumeMount.name === volume.name
|
||||
);
|
||||
if (volConfigMapItems.length === 0) {
|
||||
return [
|
||||
{
|
||||
volumeConfigName,
|
||||
containerVolumeMount,
|
||||
containerName: container.name,
|
||||
isInitContainer: appInitContainers.includes(container),
|
||||
item: {} as KeyToPath,
|
||||
},
|
||||
];
|
||||
}
|
||||
// if there are items, return a volume config for each item
|
||||
return volConfigMapItems.map((item) => ({
|
||||
volumeConfigName,
|
||||
containerVolumeMount,
|
||||
containerName: container.name,
|
||||
isInitContainer: appInitContainers.includes(container),
|
||||
item,
|
||||
}));
|
||||
})
|
||||
// only return the app volumes where the container volumeMounts include the volume name (from map step above)
|
||||
.filter((volume) => volume.containerVolumeMount);
|
||||
return matchingVolumes;
|
||||
});
|
||||
|
||||
return appVolumeConfigs;
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
import { RotateCw } from 'lucide-react';
|
||||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { notifySuccess, notifyError } from '@/portainer/services/notifications';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { useRedeployApplicationMutation } from '../../application.queries';
|
||||
import { Application } from '../../types';
|
||||
import {
|
||||
applicationIsKind,
|
||||
matchLabelsToLabelSelectorValue,
|
||||
} from '../../utils';
|
||||
|
||||
type Props = {
|
||||
environmentId: EnvironmentId;
|
||||
namespace: string;
|
||||
appName: string;
|
||||
app?: Application;
|
||||
};
|
||||
|
||||
export function RedeployApplicationButton({
|
||||
environmentId,
|
||||
namespace,
|
||||
appName,
|
||||
app,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const redeployAppMutation = useRedeployApplicationMutation(
|
||||
environmentId,
|
||||
namespace,
|
||||
appName
|
||||
);
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sPodDelete">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
color="light"
|
||||
className="!ml-0"
|
||||
disabled={redeployAppMutation.isLoading || !app}
|
||||
onClick={() => redeployApplication()}
|
||||
data-cy="k8sAppDetail-redeployButton"
|
||||
>
|
||||
<Icon icon={RotateCw} className="mr-1" />
|
||||
Redeploy
|
||||
</Button>
|
||||
</Authorized>
|
||||
);
|
||||
|
||||
async function redeployApplication() {
|
||||
// validate
|
||||
if (!app || applicationIsKind<Pod>('Pod', app)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!app?.spec?.selector?.matchLabels) {
|
||||
throw new Error(
|
||||
`Application has no 'matchLabels' selector to redeploy pods.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError('Failure', error as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// confirm the action
|
||||
const confirmed = await confirm({
|
||||
title: 'Are you sure?',
|
||||
modalType: ModalType.Warn,
|
||||
confirmButton: buildConfirmButton('Redeploy'),
|
||||
message:
|
||||
'Redeploying terminates and restarts the application, which will cause service interruption. Do you wish to continue?',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// using the matchlabels object, delete the associated pods with redeployAppMutation
|
||||
const labelSelector = matchLabelsToLabelSelectorValue(
|
||||
app?.spec?.selector?.matchLabels
|
||||
);
|
||||
redeployAppMutation.mutateAsync(
|
||||
{ labelSelector },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Application successfully redeployed');
|
||||
router.stateService.reload();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { BETeaserButton } from '@@/BETeaserButton';
|
||||
|
||||
export function RestartApplicationButton() {
|
||||
return (
|
||||
<BETeaserButton
|
||||
buttonClassName="!ml-0"
|
||||
data-cy="k8sAppDetail-restartButton"
|
||||
heading="Rolling restart"
|
||||
icon={RefreshCw}
|
||||
featureId={FeatureId.K8S_ROLLING_RESTART}
|
||||
message="A rolling restart of the application is performed."
|
||||
buttonText="Rolling restart"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { notifySuccess, notifyError } from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
||||
import {
|
||||
useApplicationRevisionList,
|
||||
usePatchApplicationMutation,
|
||||
} from '../../application.queries';
|
||||
import {
|
||||
applicationIsKind,
|
||||
getRollbackPatchPayload,
|
||||
matchLabelsToLabelSelectorValue,
|
||||
} from '../../utils';
|
||||
import { Application } from '../../types';
|
||||
import { appDeployMethodLabel } from '../../constants';
|
||||
|
||||
type Props = {
|
||||
environmentId: EnvironmentId;
|
||||
namespace: string;
|
||||
appName: string;
|
||||
app?: Application;
|
||||
};
|
||||
|
||||
export function RollbackApplicationButton({
|
||||
environmentId,
|
||||
namespace,
|
||||
appName,
|
||||
app,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const labelSelector = applicationIsKind<Pod>('Pod', app)
|
||||
? ''
|
||||
: matchLabelsToLabelSelectorValue(app?.spec?.selector?.matchLabels);
|
||||
const appRevisionListQuery = useApplicationRevisionList(
|
||||
environmentId,
|
||||
namespace,
|
||||
appName,
|
||||
app?.metadata?.uid,
|
||||
labelSelector,
|
||||
app?.kind
|
||||
);
|
||||
const appRevisionList = appRevisionListQuery.data;
|
||||
const appRevisions = appRevisionList?.items;
|
||||
const appDeployMethod =
|
||||
app?.metadata?.labels?.[appDeployMethodLabel] || 'application form';
|
||||
|
||||
const patchAppMutation = usePatchApplicationMutation(
|
||||
environmentId,
|
||||
namespace,
|
||||
appName
|
||||
);
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sApplicationDetailsW">
|
||||
<Button
|
||||
ng-if="!ctrl.isExternalApplication()"
|
||||
type="button"
|
||||
color="light"
|
||||
size="small"
|
||||
className="!ml-0"
|
||||
disabled={
|
||||
!app ||
|
||||
!appRevisions ||
|
||||
appRevisions?.length < 2 ||
|
||||
appDeployMethod !== 'application form' ||
|
||||
patchAppMutation.isLoading
|
||||
}
|
||||
onClick={() => rollbackApplication()}
|
||||
data-cy="k8sAppDetail-rollbackButton"
|
||||
>
|
||||
<Icon icon={RotateCcw} className="mr-1" />
|
||||
Rollback to previous configuration
|
||||
</Button>
|
||||
</Authorized>
|
||||
);
|
||||
|
||||
async function rollbackApplication() {
|
||||
// exit early if the application is a pod or there are no revisions
|
||||
if (
|
||||
!app?.kind ||
|
||||
applicationIsKind<Pod>('Pod', app) ||
|
||||
!appRevisionList?.items?.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// confirm the action
|
||||
const confirmed = await confirm({
|
||||
title: 'Are you sure?',
|
||||
modalType: ModalType.Warn,
|
||||
confirmButton: buildConfirmButton('Rollback'),
|
||||
message:
|
||||
'Rolling back the application to a previous configuration may cause service interruption. Do you wish to continue?',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const patch = getRollbackPatchPayload(app, appRevisionList);
|
||||
patchAppMutation.mutateAsync(
|
||||
{ appKind: app.kind, patch },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Application successfully rolled back');
|
||||
router.stateService.reload();
|
||||
},
|
||||
onError: (error) =>
|
||||
notifyError(
|
||||
'Failure',
|
||||
error as Error,
|
||||
'Unable to rollback the application'
|
||||
),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
notifyError('Failure', error as Error);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ApplicationDetailsWidget } from './ApplicationDetailsWidget';
|
|
@ -32,7 +32,7 @@ import {
|
|||
useApplication,
|
||||
usePatchApplicationMutation,
|
||||
} from '../application.queries';
|
||||
import { Application } from '../types';
|
||||
import { Application, ApplicationPatch } from '../types';
|
||||
|
||||
export function ApplicationSummaryWidget() {
|
||||
const stateAndParams = useCurrentStateAndParams();
|
||||
|
@ -263,14 +263,18 @@ export function ApplicationSummaryWidget() {
|
|||
);
|
||||
|
||||
async function patchApplicationNote() {
|
||||
const path = `/metadata/annotations/${appNoteAnnotation}`;
|
||||
const value = applicationNoteFormValues;
|
||||
const patch: ApplicationPatch = [
|
||||
{
|
||||
op: 'replace',
|
||||
path: `/metadata/annotations/${appNoteAnnotation}`,
|
||||
value: 'applicationNoteFormValues',
|
||||
},
|
||||
];
|
||||
if (application?.kind) {
|
||||
try {
|
||||
await patchApplicationMutation.mutateAsync({
|
||||
appKind: application.kind,
|
||||
path,
|
||||
value,
|
||||
patch,
|
||||
});
|
||||
notifySuccess('Success', 'Application successfully updated');
|
||||
} catch (error) {
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
|
||||
export { ApplicationDetailsWidget } from './ApplicationDetailsWidget/ApplicationDetailsWidget';
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import { useMutation, useQuery } from 'react-query';
|
||||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { queryClient, withError } from '@/react-tools/react-query';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { getNamespaceServices } from '../ServicesView/service';
|
||||
|
||||
import {
|
||||
getApplicationsForCluster,
|
||||
getApplication,
|
||||
patchApplication,
|
||||
getApplicationRevisionList,
|
||||
} from './application.service';
|
||||
import { AppKind } from './types';
|
||||
import type { AppKind, Application, ApplicationPatch } from './types';
|
||||
import { deletePod, getNamespacePods } from './pod.service';
|
||||
import { getNamespaceHorizontalPodAutoscalers } from './autoscaling.service';
|
||||
import { applicationIsKind, matchLabelsToLabelSelectorValue } from './utils';
|
||||
|
||||
const queryKeys = {
|
||||
applicationsForCluster: (environmentId: EnvironmentId) => [
|
||||
|
@ -29,6 +36,73 @@ const queryKeys = {
|
|||
namespace,
|
||||
name,
|
||||
],
|
||||
applicationRevisions: (
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string,
|
||||
labelSelector?: string
|
||||
) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'applications',
|
||||
namespace,
|
||||
name,
|
||||
'revisions',
|
||||
labelSelector,
|
||||
],
|
||||
applicationServices: (
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'applications',
|
||||
namespace,
|
||||
name,
|
||||
'services',
|
||||
],
|
||||
ingressesForApplication: (
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'applications',
|
||||
namespace,
|
||||
name,
|
||||
'ingresses',
|
||||
],
|
||||
applicationHorizontalPodAutoscalers: (
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'applications',
|
||||
namespace,
|
||||
name,
|
||||
'horizontalpodautoscalers',
|
||||
],
|
||||
applicationPods: (
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'applications',
|
||||
namespace,
|
||||
name,
|
||||
'pods',
|
||||
],
|
||||
};
|
||||
|
||||
// useQuery to get a list of all applications from an array of namespaces
|
||||
|
@ -62,6 +136,161 @@ export function useApplication(
|
|||
);
|
||||
}
|
||||
|
||||
// test if I can get the previous revision
|
||||
// useQuery to get an application's previous revision by environmentId, namespace, appKind and labelSelector
|
||||
export function useApplicationRevisionList(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string,
|
||||
deploymentUid?: string,
|
||||
labelSelector?: string,
|
||||
appKind?: AppKind
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.applicationRevisions(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
labelSelector
|
||||
),
|
||||
() =>
|
||||
getApplicationRevisionList(
|
||||
environmentId,
|
||||
namespace,
|
||||
deploymentUid,
|
||||
appKind,
|
||||
labelSelector
|
||||
),
|
||||
{
|
||||
...withError('Unable to retrieve application revisions'),
|
||||
enabled: !!labelSelector && !!appKind && !!deploymentUid,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// useApplicationServices returns a query for services that are related to the application (this doesn't include ingresses)
|
||||
// Filtering the services by the application selector labels is done in the front end because:
|
||||
// - The label selector query param in the kubernetes API filters by metadata.labels, but we need to filter the services by spec.selector
|
||||
// - The field selector query param in the kubernetes API can filter the services by spec.selector, but it doesn't support chaining with 'OR',
|
||||
// so we can't filter by services with at least one matching label. See: https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/#chained-selectors
|
||||
export function useApplicationServices(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appName: string,
|
||||
app?: Application
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.applicationServices(environmentId, namespace, appName),
|
||||
async () => {
|
||||
if (!app) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// get the selector labels for the application
|
||||
const appSelectorLabels = applicationIsKind<Pod>('Pod', app)
|
||||
? app.metadata?.labels
|
||||
: app.spec?.template?.metadata?.labels;
|
||||
|
||||
// get all services in the namespace and filter them by the application selector labels
|
||||
const services = await getNamespaceServices(environmentId, namespace);
|
||||
const filteredServices = services.filter((service) => {
|
||||
if (service.spec?.selector && appSelectorLabels) {
|
||||
const serviceSelectorLabels = service.spec.selector;
|
||||
// include the service if the service selector label matches at least one application selector label
|
||||
return Object.keys(appSelectorLabels).some(
|
||||
(key) =>
|
||||
serviceSelectorLabels[key] &&
|
||||
serviceSelectorLabels[key] === appSelectorLabels[key]
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return filteredServices;
|
||||
},
|
||||
{ ...withError(`Unable to get services for ${appName}`), enabled: !!app }
|
||||
);
|
||||
}
|
||||
|
||||
// useApplicationHorizontalPodAutoscalers returns a query for horizontal pod autoscalers that are related to the application
|
||||
export function useApplicationHorizontalPodAutoscalers(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appName: string,
|
||||
app?: Application
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.applicationHorizontalPodAutoscalers(
|
||||
environmentId,
|
||||
namespace,
|
||||
appName
|
||||
),
|
||||
async () => {
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const horizontalPodAutoscalers =
|
||||
await getNamespaceHorizontalPodAutoscalers(environmentId, namespace);
|
||||
const filteredHorizontalPodAutoscalers =
|
||||
horizontalPodAutoscalers.find((horizontalPodAutoscaler) => {
|
||||
const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef;
|
||||
if (scaleTargetRef) {
|
||||
const scaleTargetRefName = scaleTargetRef.name;
|
||||
const scaleTargetRefKind = scaleTargetRef.kind;
|
||||
// include the horizontal pod autoscaler if the scale target ref name and kind match the application name and kind
|
||||
return (
|
||||
scaleTargetRefName === app.metadata?.name &&
|
||||
scaleTargetRefKind === app.kind
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}) || null;
|
||||
return filteredHorizontalPodAutoscalers;
|
||||
},
|
||||
{
|
||||
...withError(
|
||||
`Unable to get horizontal pod autoscalers${
|
||||
app ? ` for ${app.metadata?.name}` : ''
|
||||
}`
|
||||
),
|
||||
enabled: !!app,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// useApplicationPods returns a query for pods that are related to the application by the application selector labels
|
||||
export function useApplicationPods(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appName: string,
|
||||
app?: Application
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.applicationPods(environmentId, namespace, appName),
|
||||
async () => {
|
||||
if (applicationIsKind<Pod>('Pod', app)) {
|
||||
return [app];
|
||||
}
|
||||
const appSelector = app?.spec?.selector;
|
||||
const labelSelector = matchLabelsToLabelSelectorValue(
|
||||
appSelector?.matchLabels
|
||||
);
|
||||
|
||||
// get all pods in the namespace using the application selector as the label selector query param
|
||||
const pods = await getNamespacePods(
|
||||
environmentId,
|
||||
namespace,
|
||||
labelSelector
|
||||
);
|
||||
return pods;
|
||||
},
|
||||
{
|
||||
...withError(`Unable to get pods for ${appName}`),
|
||||
enabled: !!app,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// useQuery to patch an application by environmentId, namespace, name and patch payload
|
||||
export function usePatchApplicationMutation(
|
||||
environmentId: EnvironmentId,
|
||||
|
@ -69,22 +298,54 @@ export function usePatchApplicationMutation(
|
|||
name: string
|
||||
) {
|
||||
return useMutation(
|
||||
({
|
||||
appKind,
|
||||
path,
|
||||
value,
|
||||
}: {
|
||||
appKind: AppKind;
|
||||
path: string;
|
||||
value: string;
|
||||
}) =>
|
||||
patchApplication(environmentId, namespace, appKind, name, path, value),
|
||||
({ appKind, patch }: { appKind: AppKind; patch: ApplicationPatch }) =>
|
||||
patchApplication(environmentId, namespace, appKind, name, patch),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.application(environmentId, namespace, name)
|
||||
);
|
||||
},
|
||||
// patch application is used for patching and rollbacks, so handle the error where it's used instead of here
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// useRedeployApplicationMutation gets all the pods for an application (using the matchLabels field in the labelSelector query param) and then deletes all of them, so that they are recreated
|
||||
export function useRedeployApplicationMutation(
|
||||
environmentId: number,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
return useMutation(
|
||||
async ({ labelSelector }: { labelSelector: string }) => {
|
||||
try {
|
||||
// get only the pods that match the labelSelector for the application
|
||||
const pods = await getNamespacePods(
|
||||
environmentId,
|
||||
namespace,
|
||||
labelSelector
|
||||
);
|
||||
// delete all the pods to redeploy the application
|
||||
await Promise.all(
|
||||
pods.map((pod) => {
|
||||
if (pod?.metadata?.name) {
|
||||
return deletePod(environmentId, namespace, pod.metadata.name);
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to redeploy application: ${error}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.application(environmentId, namespace, name)
|
||||
);
|
||||
},
|
||||
...withError('Unable to redeploy application'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,15 +5,23 @@ import {
|
|||
Deployment,
|
||||
DaemonSet,
|
||||
StatefulSet,
|
||||
ReplicaSetList,
|
||||
ControllerRevisionList,
|
||||
} from 'kubernetes-types/apps/v1';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||
|
||||
import { getPod, getPods, patchPod } from './pod.service';
|
||||
import { getNakedPods } from './utils';
|
||||
import { AppKind, Application, ApplicationList } from './types';
|
||||
import { getPod, getNamespacePods, patchPod } from './pod.service';
|
||||
import { filterRevisionsByOwnerUid, getNakedPods } from './utils';
|
||||
import {
|
||||
AppKind,
|
||||
Application,
|
||||
ApplicationList,
|
||||
ApplicationPatch,
|
||||
} from './types';
|
||||
import { appRevisionAnnotation } from './constants';
|
||||
|
||||
// This file contains services for Kubernetes apps/v1 resources (Deployments, DaemonSets, StatefulSets)
|
||||
|
||||
|
@ -58,7 +66,7 @@ async function getApplicationsForNamespace(
|
|||
namespace,
|
||||
'StatefulSet'
|
||||
),
|
||||
getPods(environmentId, namespace),
|
||||
getNamespacePods(environmentId, namespace),
|
||||
]);
|
||||
// find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset)
|
||||
const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets);
|
||||
|
@ -147,8 +155,7 @@ export async function patchApplication(
|
|||
namespace: string,
|
||||
appKind: AppKind,
|
||||
name: string,
|
||||
path: string,
|
||||
value: string
|
||||
patch: ApplicationPatch
|
||||
) {
|
||||
try {
|
||||
switch (appKind) {
|
||||
|
@ -158,8 +165,7 @@ export async function patchApplication(
|
|||
namespace,
|
||||
appKind,
|
||||
name,
|
||||
path,
|
||||
value
|
||||
patch
|
||||
);
|
||||
case 'DaemonSet':
|
||||
return await patchApplicationByKind<DaemonSet>(
|
||||
|
@ -167,8 +173,8 @@ export async function patchApplication(
|
|||
namespace,
|
||||
appKind,
|
||||
name,
|
||||
path,
|
||||
value
|
||||
patch,
|
||||
'application/strategic-merge-patch+json'
|
||||
);
|
||||
case 'StatefulSet':
|
||||
return await patchApplicationByKind<StatefulSet>(
|
||||
|
@ -176,11 +182,11 @@ export async function patchApplication(
|
|||
namespace,
|
||||
appKind,
|
||||
name,
|
||||
path,
|
||||
value
|
||||
patch,
|
||||
'application/strategic-merge-patch+json'
|
||||
);
|
||||
case 'Pod':
|
||||
return await patchPod(environmentId, namespace, name, path, value);
|
||||
return await patchPod(environmentId, namespace, name, patch);
|
||||
default:
|
||||
throw new Error(`Unknown application kind ${appKind}`);
|
||||
}
|
||||
|
@ -197,23 +203,16 @@ async function patchApplicationByKind<T extends Application>(
|
|||
namespace: string,
|
||||
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
|
||||
name: string,
|
||||
path: string,
|
||||
value: string
|
||||
patch: ApplicationPatch,
|
||||
contentType = 'application/json-patch+json'
|
||||
) {
|
||||
const payload = [
|
||||
{
|
||||
op: 'replace',
|
||||
path,
|
||||
value,
|
||||
},
|
||||
];
|
||||
try {
|
||||
const res = await axios.patch<T>(
|
||||
buildUrl(environmentId, namespace, `${appKind}s`, name),
|
||||
payload,
|
||||
patch,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json-patch+json',
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -254,10 +253,120 @@ async function getApplicationsByKind<T extends ApplicationList>(
|
|||
}
|
||||
}
|
||||
|
||||
export async function getApplicationRevisionList(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
deploymentUid?: string,
|
||||
appKind?: AppKind,
|
||||
labelSelector?: string
|
||||
) {
|
||||
if (!deploymentUid) {
|
||||
throw new Error('deploymentUid is required');
|
||||
}
|
||||
try {
|
||||
switch (appKind) {
|
||||
case 'Deployment': {
|
||||
const replicaSetList = await getReplicaSetList(
|
||||
environmentId,
|
||||
namespace,
|
||||
labelSelector
|
||||
);
|
||||
const replicaSets = replicaSetList.items;
|
||||
// keep only replicaset(s) which are owned by the deployment with the given uid
|
||||
const replicaSetsWithOwnerId = filterRevisionsByOwnerUid(
|
||||
replicaSets,
|
||||
deploymentUid
|
||||
);
|
||||
// keep only replicaset(s) that have been a version of the Deployment
|
||||
const replicaSetsWithRevisionAnnotations =
|
||||
replicaSetsWithOwnerId.filter(
|
||||
(rs) => !!rs.metadata?.annotations?.[appRevisionAnnotation]
|
||||
);
|
||||
|
||||
return {
|
||||
...replicaSetList,
|
||||
items: replicaSetsWithRevisionAnnotations,
|
||||
} as ReplicaSetList;
|
||||
}
|
||||
case 'DaemonSet':
|
||||
case 'StatefulSet': {
|
||||
const controllerRevisionList = await getControllerRevisionList(
|
||||
environmentId,
|
||||
namespace,
|
||||
labelSelector
|
||||
);
|
||||
const controllerRevisions = controllerRevisionList.items;
|
||||
// ensure the controller reference(s) is owned by the deployment with the given uid
|
||||
const controllerRevisionsWithOwnerId = filterRevisionsByOwnerUid(
|
||||
controllerRevisions,
|
||||
deploymentUid
|
||||
);
|
||||
|
||||
return {
|
||||
...controllerRevisionList,
|
||||
items: controllerRevisionsWithOwnerId,
|
||||
} as ControllerRevisionList;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown application kind ${appKind}`);
|
||||
}
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
`Unable to retrieve revisions for ${appKind}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReplicaSetList(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
labelSelector?: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<ReplicaSetList>(
|
||||
buildUrl(environmentId, namespace, 'ReplicaSets'),
|
||||
{
|
||||
params: {
|
||||
labelSelector,
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve ReplicaSets');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getControllerRevisionList(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
labelSelector?: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<ControllerRevisionList>(
|
||||
buildUrl(environmentId, namespace, 'ControllerRevisions'),
|
||||
{
|
||||
params: {
|
||||
labelSelector,
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve ControllerRevisions');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appKind: 'Deployments' | 'DaemonSets' | 'StatefulSets',
|
||||
appKind:
|
||||
| 'Deployments'
|
||||
| 'DaemonSets'
|
||||
| 'StatefulSets'
|
||||
| 'ReplicaSets'
|
||||
| 'ControllerRevisions',
|
||||
name?: string
|
||||
) {
|
||||
let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`;
|
||||
|
|
14
app/react/kubernetes/applications/autoscaling.service.ts
Normal file
14
app/react/kubernetes/applications/autoscaling.service.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { HorizontalPodAutoscalerList } from 'kubernetes-types/autoscaling/v1';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export async function getNamespaceHorizontalPodAutoscalers(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
const { data: autoScalarList } = await axios.get<HorizontalPodAutoscalerList>(
|
||||
`/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers`
|
||||
);
|
||||
return autoScalarList.items;
|
||||
}
|
|
@ -2,9 +2,25 @@ import { AppKind, DeploymentType } from './types';
|
|||
|
||||
// Portainer specific labels
|
||||
export const appStackNameLabel = 'io.portainer.kubernetes.application.stack';
|
||||
export const appStackIdLabel = 'io.portainer.kubernetes.application.stackid';
|
||||
export const appOwnerLabel = 'io.portainer.kubernetes.application.owner';
|
||||
export const appNoteAnnotation = 'io.portainer.kubernetes.application.note';
|
||||
export const appDeployMethodLabel = 'io.portainer.kubernetes.application.kind';
|
||||
export const defaultDeploymentUniqueLabel = 'pod-template-hash';
|
||||
|
||||
export const appRevisionAnnotation = 'deployment.kubernetes.io/revision';
|
||||
|
||||
// unchangedAnnotationKeysForRollbackPatch lists the annotations that should be preserved from the deployment and not
|
||||
// copied from the replicaset when rolling a deployment back
|
||||
export const unchangedAnnotationKeysForRollbackPatch = [
|
||||
'kubectl.kubernetes.io/last-applied-configuration',
|
||||
appRevisionAnnotation,
|
||||
'deployment.kubernetes.io/revision-history',
|
||||
'deployment.kubernetes.io/desired-replicas',
|
||||
'deployment.kubernetes.io/max-replicas',
|
||||
'deprecated.deployment.rollback.to',
|
||||
'deprecated.daemonset.template.generation',
|
||||
];
|
||||
|
||||
export const appKindToDeploymentTypeMap: Record<
|
||||
AppKind,
|
||||
|
|
|
@ -1,12 +1,23 @@
|
|||
import { Pod, PodList } from 'kubernetes-types/core/v1';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
export async function getPods(environmentId: EnvironmentId, namespace: string) {
|
||||
import { ApplicationPatch } from './types';
|
||||
|
||||
export async function getNamespacePods(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
labelSelector?: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<PodList>(
|
||||
buildUrl(environmentId, namespace)
|
||||
buildUrl(environmentId, namespace),
|
||||
{
|
||||
params: {
|
||||
labelSelector,
|
||||
},
|
||||
}
|
||||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
|
@ -33,20 +44,12 @@ export async function patchPod(
|
|||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string,
|
||||
path: string,
|
||||
value: string
|
||||
patch: ApplicationPatch
|
||||
) {
|
||||
const payload = [
|
||||
{
|
||||
op: 'replace',
|
||||
path,
|
||||
value,
|
||||
},
|
||||
];
|
||||
try {
|
||||
return await axios.patch<Pod>(
|
||||
buildUrl(environmentId, namespace, name),
|
||||
payload,
|
||||
patch,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json-patch+json',
|
||||
|
@ -58,6 +61,18 @@ export async function patchPod(
|
|||
}
|
||||
}
|
||||
|
||||
export async function deletePod(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
try {
|
||||
return await axios.delete<Pod>(buildUrl(environmentId, namespace, name));
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to delete pod');
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
|
|
|
@ -5,11 +5,18 @@ import {
|
|||
DeploymentList,
|
||||
StatefulSet,
|
||||
StatefulSetList,
|
||||
ReplicaSet,
|
||||
ControllerRevision,
|
||||
} from 'kubernetes-types/apps/v1';
|
||||
import { Pod, PodList } from 'kubernetes-types/core/v1';
|
||||
import { RawExtension } from 'kubernetes-types/runtime';
|
||||
|
||||
export type Application = Deployment | DaemonSet | StatefulSet | Pod;
|
||||
|
||||
// Revisions are have the previous application state and are used for rolling back applications to their previous state.
|
||||
// Deployments use ReplicaSets, StatefulSets and DaemonSets use ControllerRevisions, and Pods don't have revisions.
|
||||
export type Revision = ReplicaSet | ControllerRevision;
|
||||
|
||||
export type ApplicationList =
|
||||
| DeploymentList
|
||||
| DaemonSetList
|
||||
|
@ -19,3 +26,11 @@ export type ApplicationList =
|
|||
export type AppKind = 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod';
|
||||
|
||||
export type DeploymentType = 'Replicated' | 'Global';
|
||||
|
||||
type Patch = {
|
||||
op: 'replace' | 'add' | 'remove';
|
||||
path: string;
|
||||
value: string | number | boolean | null | Record<string, unknown>;
|
||||
}[];
|
||||
|
||||
export type ApplicationPatch = Patch | RawExtension;
|
||||
|
|
|
@ -1,18 +1,32 @@
|
|||
import { Deployment, DaemonSet, StatefulSet } from 'kubernetes-types/apps/v1';
|
||||
import {
|
||||
Deployment,
|
||||
DaemonSet,
|
||||
StatefulSet,
|
||||
ReplicaSet,
|
||||
ReplicaSetList,
|
||||
ControllerRevisionList,
|
||||
ControllerRevision,
|
||||
} from 'kubernetes-types/apps/v1';
|
||||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
|
||||
import { Application } from './types';
|
||||
import { appOwnerLabel } from './constants';
|
||||
import { Application, ApplicationPatch, Revision } from './types';
|
||||
import {
|
||||
appOwnerLabel,
|
||||
defaultDeploymentUniqueLabel,
|
||||
unchangedAnnotationKeysForRollbackPatch,
|
||||
appRevisionAnnotation,
|
||||
} from './constants';
|
||||
|
||||
// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset
|
||||
// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs
|
||||
// getNakedPods returns an array of naked pods from an array of pods, deployments, daemonsets and statefulsets
|
||||
export function getNakedPods(
|
||||
pods: Pod[],
|
||||
deployments: Deployment[],
|
||||
daemonSets: DaemonSet[],
|
||||
statefulSets: StatefulSet[]
|
||||
) {
|
||||
// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset
|
||||
// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs
|
||||
const appLabels = [
|
||||
...deployments.map((deployment) => deployment.spec?.selector.matchLabels),
|
||||
...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels),
|
||||
|
@ -37,7 +51,7 @@ export function getNakedPods(
|
|||
return nakedPods;
|
||||
}
|
||||
|
||||
// type guard to check if an application is a deployment, daemonset statefulset or pod
|
||||
// type guard to check if an application is a deployment, daemonset, statefulset or pod
|
||||
export function applicationIsKind<T extends Application>(
|
||||
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod',
|
||||
application?: Application
|
||||
|
@ -165,3 +179,151 @@ export function getResourceLimits(application: Application) {
|
|||
|
||||
return limits;
|
||||
}
|
||||
|
||||
// matchLabelsToLabelSelectorValue converts a map of labels to a label selector value that can be used in the
|
||||
// labelSelector param for the kube api to filter kube resources by labels
|
||||
export function matchLabelsToLabelSelectorValue(obj?: Record<string, string>) {
|
||||
if (!obj) return '';
|
||||
return Object.entries(obj)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
// filterRevisionsByOwnerUid filters a list of revisions to only include revisions that have the given uid in their
|
||||
// ownerReferences
|
||||
export function filterRevisionsByOwnerUid<T extends Revision>(
|
||||
revisions: T[],
|
||||
uid: string
|
||||
) {
|
||||
return revisions.filter((revision) => {
|
||||
const ownerReferencesUids =
|
||||
revision.metadata?.ownerReferences?.map((or) => or.uid) || [];
|
||||
return ownerReferencesUids.includes(uid);
|
||||
});
|
||||
}
|
||||
|
||||
// getRollbackPatchPayload returns the patch payload to rollback a deployment to the previous revision
|
||||
// the patch should be able to update the deployment's template to the previous revision's template
|
||||
export function getRollbackPatchPayload(
|
||||
application: Deployment | StatefulSet | DaemonSet,
|
||||
revisionList: ReplicaSetList | ControllerRevisionList
|
||||
): ApplicationPatch {
|
||||
switch (revisionList.kind) {
|
||||
case 'ControllerRevisionList': {
|
||||
const previousRevision = getPreviousControllerRevision(
|
||||
revisionList.items
|
||||
);
|
||||
if (!previousRevision.data) {
|
||||
throw new Error('No data found in the previous revision.');
|
||||
}
|
||||
return previousRevision.data;
|
||||
}
|
||||
case 'ReplicaSetList': {
|
||||
const previousRevision = getPreviousReplicaSetRevision(
|
||||
revisionList.items
|
||||
);
|
||||
|
||||
// remove hash label before patching back into the deployment
|
||||
const revisionTemplate = previousRevision.spec?.template;
|
||||
if (revisionTemplate?.metadata?.labels) {
|
||||
delete revisionTemplate.metadata.labels[defaultDeploymentUniqueLabel];
|
||||
}
|
||||
|
||||
// build the patch payload for the deployment from the replica set
|
||||
// keep the annotations to skip from the deployment, in the patch
|
||||
const applicationAnnotations = application.metadata?.annotations || {};
|
||||
const applicationAnnotationsInPatch =
|
||||
unchangedAnnotationKeysForRollbackPatch.reduce((acc, annotationKey) => {
|
||||
if (applicationAnnotations[annotationKey]) {
|
||||
acc[annotationKey] = applicationAnnotations[annotationKey];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
// add any annotations from the target revision that shouldn't be skipped
|
||||
const revisionAnnotations = previousRevision.metadata?.annotations || {};
|
||||
const revisionAnnotationsInPatch = Object.entries(
|
||||
revisionAnnotations
|
||||
).reduce((acc, [annotationKey, annotationValue]) => {
|
||||
if (!unchangedAnnotationKeysForRollbackPatch.includes(annotationKey)) {
|
||||
acc[annotationKey] = annotationValue;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const patchAnnotations = {
|
||||
...applicationAnnotationsInPatch,
|
||||
...revisionAnnotationsInPatch,
|
||||
};
|
||||
|
||||
// Create a patch of the Deployment that replaces spec.template
|
||||
const deploymentRollbackPatch = [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/spec/template',
|
||||
value: revisionTemplate,
|
||||
},
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/metadata/annotations',
|
||||
value: patchAnnotations,
|
||||
},
|
||||
].filter((p) => !!p.value); // remove any patch that has no value
|
||||
|
||||
return deploymentRollbackPatch;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown revision list kind ${revisionList.kind}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviousReplicaSetRevision(replicaSets: ReplicaSet[]) {
|
||||
// sort replicaset(s) using the revision annotation number (old to new).
|
||||
// Kubectl uses the same revision annotation key to determine the previous version
|
||||
// (see the Revision function, and where it's used https://github.com/kubernetes/kubectl/blob/27ec3dafa658d8873b3d9287421d636048b51921/pkg/util/deployment/deployment.go#LL70C11-L70C11)
|
||||
const sortedReplicaSets = replicaSets.sort((a, b) => {
|
||||
const aRevision =
|
||||
Number(a.metadata?.annotations?.[appRevisionAnnotation]) || 0;
|
||||
const bRevision =
|
||||
Number(b.metadata?.annotations?.[appRevisionAnnotation]) || 0;
|
||||
return aRevision - bRevision;
|
||||
});
|
||||
|
||||
// if there are less than 2 revisions, there is no previous revision to rollback to
|
||||
if (sortedReplicaSets.length < 2) {
|
||||
throw new Error(
|
||||
'There are no previous revisions to rollback to. Please check the application revisions.'
|
||||
);
|
||||
}
|
||||
|
||||
// get the second to last revision
|
||||
const previousRevision = sortedReplicaSets[sortedReplicaSets.length - 2];
|
||||
return previousRevision;
|
||||
}
|
||||
|
||||
function getPreviousControllerRevision(
|
||||
controllerRevisions: ControllerRevision[]
|
||||
) {
|
||||
// sort the list of ControllerRevisions by revision, using the creationTimestamp as a tie breaker (old to new)
|
||||
const sortedControllerRevisions = controllerRevisions.sort((a, b) => {
|
||||
if (a.revision === b.revision) {
|
||||
return (
|
||||
new Date(a.metadata?.creationTimestamp || '').getTime() -
|
||||
new Date(b.metadata?.creationTimestamp || '').getTime()
|
||||
);
|
||||
}
|
||||
return a.revision - b.revision;
|
||||
});
|
||||
|
||||
// if there are less than 2 revisions, there is no previous revision to rollback to
|
||||
if (sortedControllerRevisions.length < 2) {
|
||||
throw new Error(
|
||||
'There are no previous revisions to rollback to. Please check the application revisions.'
|
||||
);
|
||||
}
|
||||
|
||||
// get the second to last revision
|
||||
const previousRevision =
|
||||
sortedControllerRevisions[sortedControllerRevisions.length - 2];
|
||||
return previousRevision;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue