1
0
Fork 0
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:
Ali 2023-05-29 15:06:14 +12:00 committed by GitHub
parent fdd79cece8
commit af77e33993
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 2046 additions and 1079 deletions

View file

@ -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>
)}
</>
);
}

View file

@ -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>
);
}

View file

@ -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];
}

View file

@ -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;
}

View file

@ -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 [];
}

View file

@ -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>
</>
)}
</>
);
}

View file

@ -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;
}

View file

@ -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();
},
}
);
}
}

View file

@ -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"
/>
);
}

View file

@ -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);
}
}
}

View file

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

View file

@ -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) {

View file

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