mirror of
https://github.com/portainer/portainer.git
synced 2025-07-25 00:09:40 +02:00
feat(app): migrate app parent view to react [EE-5361] (#10086)
Co-authored-by: testa113 <testa113>
This commit is contained in:
parent
531f88b947
commit
841ca1ebd4
42 changed files with 1448 additions and 810 deletions
|
@ -0,0 +1,44 @@
|
|||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Widget } from '@@/Widget/Widget';
|
||||
import { WidgetBody } from '@@/Widget';
|
||||
|
||||
import { YAMLInspector } from '../../../components/YAMLInspector';
|
||||
|
||||
import { useApplicationYAML } from './useApplicationYAML';
|
||||
|
||||
// the yaml currently has the yaml from the app, related services and horizontal pod autoscalers
|
||||
// TODO: this could be extended to include other related resources like ingresses, etc.
|
||||
export function ApplicationYAMLEditor() {
|
||||
const { fullApplicationYaml, isApplicationYAMLLoading } =
|
||||
useApplicationYAML();
|
||||
|
||||
if (isApplicationYAMLLoading) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<InlineLoader>Loading application YAML...</InlineLoader>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<YAMLInspector
|
||||
identifier="application-yaml"
|
||||
data={fullApplicationYaml}
|
||||
hideMessage
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useServicesQuery } from '@/react/kubernetes/services/service';
|
||||
|
||||
import {
|
||||
useApplication,
|
||||
useApplicationHorizontalPodAutoscaler,
|
||||
useApplicationServices,
|
||||
} from '../../application.queries';
|
||||
import { useHorizontalAutoScalarQuery } from '../../autoscaling.service';
|
||||
|
||||
export function useApplicationYAML() {
|
||||
const {
|
||||
params: {
|
||||
namespace,
|
||||
name,
|
||||
'resource-type': resourceType,
|
||||
endpointId: environmentId,
|
||||
},
|
||||
} = useCurrentStateAndParams();
|
||||
// find the application and the yaml for it
|
||||
const { data: application, ...applicationQuery } = useApplication(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
resourceType
|
||||
);
|
||||
const { data: applicationYAML, ...applicationYAMLQuery } =
|
||||
useApplication<string>(environmentId, namespace, name, resourceType, {
|
||||
yaml: true,
|
||||
});
|
||||
|
||||
// find the matching services, then get the yaml for them
|
||||
const { data: services, ...servicesQuery } = useApplicationServices(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
application
|
||||
);
|
||||
const serviceNames =
|
||||
services?.flatMap((service) => service.metadata?.name || []) || [];
|
||||
const { data: servicesYAML, ...servicesYAMLQuery } = useServicesQuery<string>(
|
||||
environmentId,
|
||||
namespace,
|
||||
serviceNames,
|
||||
{ yaml: true }
|
||||
);
|
||||
|
||||
// find the matching autoscalar, then get the yaml for it
|
||||
const { data: autoScalar, ...autoScalarsQuery } =
|
||||
useApplicationHorizontalPodAutoscaler(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
application
|
||||
);
|
||||
const { data: autoScalarYAML, ...autoScalarYAMLQuery } =
|
||||
useHorizontalAutoScalarQuery<string>(
|
||||
environmentId,
|
||||
namespace,
|
||||
autoScalar?.metadata?.name || '',
|
||||
{ yaml: true }
|
||||
);
|
||||
|
||||
const fullApplicationYaml = useMemo(() => {
|
||||
const yamlArray = [
|
||||
applicationYAML,
|
||||
...(servicesYAML || []),
|
||||
autoScalarYAML,
|
||||
].flatMap((yaml) => yaml || []);
|
||||
|
||||
const yamlString = yamlArray.join('\n---\n');
|
||||
return yamlString;
|
||||
}, [applicationYAML, autoScalarYAML, servicesYAML]);
|
||||
|
||||
const isApplicationYAMLLoading =
|
||||
applicationQuery.isLoading ||
|
||||
servicesQuery.isLoading ||
|
||||
autoScalarsQuery.isLoading ||
|
||||
applicationYAMLQuery.isLoading ||
|
||||
servicesYAMLQuery.isLoading ||
|
||||
autoScalarYAMLQuery.isLoading;
|
||||
|
||||
return { fullApplicationYaml, isApplicationYAMLLoading };
|
||||
}
|
|
@ -58,7 +58,7 @@ export function ApplicationContainersDatatable() {
|
|||
emptyContentLabel="No containers found"
|
||||
title="Application containers"
|
||||
titleIcon={Server}
|
||||
getRowId={(row) => row.name}
|
||||
getRowId={(row) => row.podName} // use pod name because it's unique (name is not unique)
|
||||
disableSelect
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
import { AlertTriangle, Code, History, Minimize2 } from 'lucide-react';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import LaptopCode from '@/assets/ico/laptop-code.svg?c';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Tab, WidgetTabs, findSelectedTabIndex } from '@@/Widget/WidgetTabs';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import { EventsDatatable } from '../../components/KubernetesEventsDatatable';
|
||||
|
||||
import {
|
||||
PlacementsDatatable,
|
||||
usePlacementTableData,
|
||||
usePlacementTableState,
|
||||
} from './PlacementsDatatable';
|
||||
import { ApplicationDetailsWidget } from './ApplicationDetailsWidget';
|
||||
import { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
|
||||
import { ApplicationContainersDatatable } from './ApplicationContainersDatatable';
|
||||
import {
|
||||
useApplicationEventsTableData,
|
||||
useApplicationEventsTableState,
|
||||
} from './useApplicationEventsTableData';
|
||||
import { ApplicationYAMLEditor } from './AppYAMLEditor/ApplicationYAMLEditor';
|
||||
import { useApplicationYAML } from './AppYAMLEditor/useApplicationYAML';
|
||||
|
||||
export function ApplicationDetailsView() {
|
||||
const stateAndParams = useCurrentStateAndParams();
|
||||
const {
|
||||
params: { namespace, name },
|
||||
} = stateAndParams;
|
||||
|
||||
// placements table data
|
||||
const { placementsData, isPlacementsTableLoading, hasPlacementWarning } =
|
||||
usePlacementTableData();
|
||||
const placementsTableState = usePlacementTableState();
|
||||
|
||||
// events table data
|
||||
const { appEventsData, appEventWarningCount, isAppEventsTableLoading } =
|
||||
useApplicationEventsTableData();
|
||||
const appEventsTableState = useApplicationEventsTableState();
|
||||
|
||||
// load app yaml data early to load from cache later
|
||||
useApplicationYAML();
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
name: 'Application',
|
||||
icon: LaptopCode,
|
||||
widget: <ApplicationSummaryWidget />,
|
||||
selectedTabParam: 'application',
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
Placement
|
||||
{hasPlacementWarning && (
|
||||
<Badge type="warnSecondary">
|
||||
<Icon icon={AlertTriangle} className="!mr-1" />1
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
icon: Minimize2,
|
||||
widget: (
|
||||
<PlacementsDatatable
|
||||
hasPlacementWarning={hasPlacementWarning}
|
||||
tableState={placementsTableState}
|
||||
dataset={placementsData}
|
||||
isLoading={isPlacementsTableLoading}
|
||||
/>
|
||||
),
|
||||
selectedTabParam: 'placement',
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
Events
|
||||
{appEventWarningCount >= 1 && (
|
||||
<Badge type="warnSecondary">
|
||||
<Icon icon={AlertTriangle} className="!mr-1" />
|
||||
{appEventWarningCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
icon: History,
|
||||
widget: (
|
||||
<EventsDatatable
|
||||
dataset={appEventsData}
|
||||
tableState={appEventsTableState}
|
||||
isLoading={isAppEventsTableLoading}
|
||||
data-cy="k8sAppDetail-eventsTable"
|
||||
/>
|
||||
),
|
||||
selectedTabParam: 'events',
|
||||
},
|
||||
{
|
||||
name: 'YAML',
|
||||
icon: Code,
|
||||
widget: <ApplicationYAMLEditor />,
|
||||
selectedTabParam: 'YAML',
|
||||
},
|
||||
];
|
||||
const currentTabIndex = findSelectedTabIndex(stateAndParams, tabs);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Application details"
|
||||
breadcrumbs={[
|
||||
{ label: 'Namespaces', link: 'kubernetes.resourcePools' },
|
||||
{
|
||||
label: namespace,
|
||||
link: 'kubernetes.resourcePools.resourcePool',
|
||||
linkParams: { id: namespace },
|
||||
},
|
||||
{ label: 'Applications', link: 'kubernetes.applications' },
|
||||
name,
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
<>
|
||||
<WidgetTabs tabs={tabs} currentTabIndex={currentTabIndex} />
|
||||
{tabs[currentTabIndex].widget}
|
||||
<ApplicationDetailsWidget />
|
||||
<ApplicationContainersDatatable />
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -7,7 +7,7 @@ import { TextTip } from '@@/Tip/TextTip';
|
|||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
import { Application } from '../../types';
|
||||
import { useApplicationHorizontalPodAutoscalers } from '../../application.queries';
|
||||
import { useApplicationHorizontalPodAutoscaler } from '../../application.queries';
|
||||
|
||||
type Props = {
|
||||
environmentId: EnvironmentId;
|
||||
|
@ -22,7 +22,7 @@ export function ApplicationAutoScalingTable({
|
|||
appName,
|
||||
app,
|
||||
}: Props) {
|
||||
const { data: appAutoScalar } = useApplicationHorizontalPodAutoscalers(
|
||||
const { data: appAutoScalar } = useApplicationHorizontalPodAutoscaler(
|
||||
environmentId,
|
||||
namespace,
|
||||
appName,
|
||||
|
|
|
@ -40,8 +40,12 @@ export function ApplicationDetailsWidget() {
|
|||
} = stateAndParams;
|
||||
|
||||
// get app info
|
||||
const appQuery = useApplication(environmentId, namespace, name, resourceType);
|
||||
const app = appQuery.data;
|
||||
const { data: app } = useApplication(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
resourceType
|
||||
);
|
||||
const externalApp = app && isExternalApplication(app);
|
||||
const appStackId = Number(app?.metadata?.labels?.[appStackIdLabel]);
|
||||
const appStackFileQuery = useStackFile(appStackId);
|
||||
|
@ -53,90 +57,94 @@ export function ApplicationDetailsWidget() {
|
|||
);
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<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>
|
||||
)}
|
||||
{!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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { User, Clock, Edit, ChevronRight, ChevronUp } from 'lucide-react';
|
||||
import { User, Clock, Info } from 'lucide-react';
|
||||
import moment from 'moment';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
|
@ -10,7 +10,11 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
|||
import { DetailsTable } from '@@/DetailsTable';
|
||||
import { Badge } from '@@/Badge';
|
||||
import { Link } from '@@/Link';
|
||||
import { Button, LoadingButton } from '@@/buttons';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { WidgetBody, Widget } from '@@/Widget';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Note } from '@@/Note';
|
||||
|
||||
import { isSystemNamespace } from '../../namespaces/utils';
|
||||
import {
|
||||
|
@ -44,13 +48,12 @@ export function ApplicationSummaryWidget() {
|
|||
endpointId: environmentId,
|
||||
},
|
||||
} = stateAndParams;
|
||||
const applicationQuery = useApplication(
|
||||
const { data: application, ...applicationQuery } = useApplication(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
resourceType
|
||||
);
|
||||
const application = applicationQuery.data;
|
||||
const systemNamespace = isSystemNamespace(namespace);
|
||||
const externalApplication = application && isExternalApplication(application);
|
||||
const applicationRequests = application && getResourceRequests(application);
|
||||
|
@ -59,7 +62,6 @@ export function ApplicationSummaryWidget() {
|
|||
const applicationNote =
|
||||
application?.metadata?.annotations?.[appNoteAnnotation];
|
||||
|
||||
const [isNoteOpen, setIsNoteOpen] = useState(true);
|
||||
const [applicationNoteFormValues, setApplicationNoteFormValues] =
|
||||
useState('');
|
||||
|
||||
|
@ -67,6 +69,9 @@ export function ApplicationSummaryWidget() {
|
|||
setApplicationNoteFormValues(applicationNote || '');
|
||||
}, [applicationNote]);
|
||||
|
||||
const failedCreateCondition = application?.status?.conditions?.find(
|
||||
(condition) => condition.reason === 'FailedCreate'
|
||||
);
|
||||
const patchApplicationMutation = usePatchApplicationMutation(
|
||||
environmentId,
|
||||
namespace,
|
||||
|
@ -74,191 +79,197 @@ export function ApplicationSummaryWidget() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<DetailsTable>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>
|
||||
<div
|
||||
className="flex items-center gap-x-2"
|
||||
data-cy="k8sAppDetail-appName"
|
||||
>
|
||||
{name}
|
||||
{externalApplication && !systemNamespace && (
|
||||
<Badge type="info">external</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stack</td>
|
||||
<td data-cy="k8sAppDetail-stackName">
|
||||
{application?.metadata?.labels?.[appStackNameLabel] || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Namespace</td>
|
||||
<td>
|
||||
<div
|
||||
className="flex items-center gap-x-2"
|
||||
data-cy="k8sAppDetail-resourcePoolName"
|
||||
>
|
||||
<Link
|
||||
to="kubernetes.resourcePools.resourcePool"
|
||||
params={{ id: namespace }}
|
||||
>
|
||||
{namespace}
|
||||
</Link>
|
||||
{systemNamespace && <Badge type="info">system</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Application type</td>
|
||||
<td data-cy="k8sAppDetail-appType">{application?.kind || '-'}</td>
|
||||
</tr>
|
||||
{application?.kind && (
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
{applicationIsKind<Pod>('Pod', application) && (
|
||||
<td data-cy="k8sAppDetail-appType">
|
||||
{application?.status?.phase}
|
||||
</td>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
{applicationQuery.isLoading && (
|
||||
<InlineLoader>Loading application...</InlineLoader>
|
||||
)}
|
||||
{!applicationIsKind<Pod>('Pod', application) && (
|
||||
<td data-cy="k8sAppDetail-appType">
|
||||
{appKindToDeploymentTypeMap[application.kind]}
|
||||
<code className="ml-1">
|
||||
{getRunningPods(application)}
|
||||
</code> / <code>{getTotalPods(application)}</code>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
{(!!applicationRequests?.cpu || !!applicationRequests?.memoryBytes) && (
|
||||
<tr>
|
||||
<td>
|
||||
Resource reservations
|
||||
{!applicationIsKind<Pod>('Pod', application) && (
|
||||
<div className="text-muted small">per instance</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!!applicationRequests?.cpu && (
|
||||
<div data-cy="k8sAppDetail-cpuReservation">
|
||||
CPU {applicationRequests.cpu}
|
||||
</div>
|
||||
)}
|
||||
{!!applicationRequests?.memoryBytes && (
|
||||
<div data-cy="k8sAppDetail-memoryReservation">
|
||||
Memory{' '}
|
||||
{bytesToReadableFormat(applicationRequests.memoryBytes)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Creation</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{applicationOwner && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-cy="k8sAppDetail-owner"
|
||||
>
|
||||
<User />
|
||||
{applicationOwner}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-cy="k8sAppDetail-creationDate"
|
||||
>
|
||||
<Clock />
|
||||
{moment(application?.metadata?.creationTimestamp).format(
|
||||
'YYYY-MM-DD HH:mm:ss'
|
||||
)}
|
||||
</span>
|
||||
{(!externalApplication || systemNamespace) && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-cy="k8sAppDetail-creationMethod"
|
||||
>
|
||||
<Clock />
|
||||
Deployed from {applicationDeployMethod}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<form className="form-horizontal">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 vertical-center">
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
color="none"
|
||||
data-cy="k8sAppDetail-expandNoteButton"
|
||||
onClick={() => setIsNoteOpen(!isNoteOpen)}
|
||||
className="!m-0 !p-0"
|
||||
{application && (
|
||||
<>
|
||||
{failedCreateCondition && (
|
||||
<div
|
||||
className="vertical-center alert alert-danger mb-2"
|
||||
data-cy="k8sAppDetail-failedCreateMessage"
|
||||
>
|
||||
{isNoteOpen ? <ChevronUp /> : <ChevronRight />} <Edit />{' '}
|
||||
Note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isNoteOpen && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<textarea
|
||||
className="form-control resize-y"
|
||||
name="application_note"
|
||||
id="application_note"
|
||||
value={applicationNoteFormValues}
|
||||
onChange={(e) =>
|
||||
setApplicationNoteFormValues(e.target.value)
|
||||
}
|
||||
rows={5}
|
||||
placeholder="Enter a note about this application..."
|
||||
/>
|
||||
<Icon icon={Info} className="mr-1" mode="danger" />
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
Failed to create application
|
||||
</div>
|
||||
{failedCreateCondition.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Authorized authorizations="K8sApplicationDetailsW">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
color="primary"
|
||||
size="small"
|
||||
className="!ml-0"
|
||||
type="button"
|
||||
onClick={() => patchApplicationNote()}
|
||||
disabled={
|
||||
// disable if there is no change to the note, or it's updating
|
||||
applicationNoteFormValues ===
|
||||
(applicationNote || '') ||
|
||||
patchApplicationMutation.isLoading
|
||||
}
|
||||
data-cy="k8sAppDetail-saveNoteButton"
|
||||
isLoading={patchApplicationMutation.isLoading}
|
||||
loadingText={applicationNote ? 'Updating' : 'Saving'}
|
||||
>
|
||||
{applicationNote ? 'Update' : 'Save'} note
|
||||
</LoadingButton>
|
||||
)}
|
||||
<DetailsTable>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>
|
||||
<div
|
||||
className="flex items-center gap-x-2"
|
||||
data-cy="k8sAppDetail-appName"
|
||||
>
|
||||
{name}
|
||||
{externalApplication && !systemNamespace && (
|
||||
<Badge type="info">external</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</DetailsTable>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stack</td>
|
||||
<td data-cy="k8sAppDetail-stackName">
|
||||
{application?.metadata?.labels?.[appStackNameLabel] ||
|
||||
'-'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Namespace</td>
|
||||
<td>
|
||||
<div
|
||||
className="flex items-center gap-x-2"
|
||||
data-cy="k8sAppDetail-resourcePoolName"
|
||||
>
|
||||
<Link
|
||||
to="kubernetes.resourcePools.resourcePool"
|
||||
params={{ id: namespace }}
|
||||
>
|
||||
{namespace}
|
||||
</Link>
|
||||
{systemNamespace && <Badge type="info">system</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Application type</td>
|
||||
<td data-cy="k8sAppDetail-appType">
|
||||
{application?.kind || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{application?.kind && (
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
{applicationIsKind<Pod>('Pod', application) && (
|
||||
<td data-cy="k8sAppDetail-appType">
|
||||
{application?.status?.phase}
|
||||
</td>
|
||||
)}
|
||||
{!applicationIsKind<Pod>('Pod', application) && (
|
||||
<td data-cy="k8sAppDetail-appType">
|
||||
{appKindToDeploymentTypeMap[application.kind]}
|
||||
<code className="ml-1">
|
||||
{getRunningPods(application)}
|
||||
</code>{' '}
|
||||
/ <code>{getTotalPods(application)}</code>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
{(!!applicationRequests?.cpu ||
|
||||
!!applicationRequests?.memoryBytes) && (
|
||||
<tr>
|
||||
<td>
|
||||
Resource reservations
|
||||
{!applicationIsKind<Pod>('Pod', application) && (
|
||||
<div className="text-muted small">per instance</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!!applicationRequests?.cpu && (
|
||||
<div data-cy="k8sAppDetail-cpuReservation">
|
||||
CPU {applicationRequests.cpu}
|
||||
</div>
|
||||
)}
|
||||
{!!applicationRequests?.memoryBytes && (
|
||||
<div data-cy="k8sAppDetail-memoryReservation">
|
||||
Memory{' '}
|
||||
{bytesToReadableFormat(
|
||||
applicationRequests.memoryBytes
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Creation</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{applicationOwner && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-cy="k8sAppDetail-owner"
|
||||
>
|
||||
<User />
|
||||
{applicationOwner}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-cy="k8sAppDetail-creationDate"
|
||||
>
|
||||
<Clock />
|
||||
{moment(
|
||||
application?.metadata?.creationTimestamp
|
||||
).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</span>
|
||||
{(!externalApplication || systemNamespace) && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-cy="k8sAppDetail-creationMethod"
|
||||
>
|
||||
<Clock />
|
||||
Deployed from {applicationDeployMethod}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<form className="form-horizontal">
|
||||
<Note
|
||||
value={applicationNoteFormValues}
|
||||
onChange={setApplicationNoteFormValues}
|
||||
defaultIsOpen
|
||||
isExpandable
|
||||
/>
|
||||
<Authorized authorizations="K8sApplicationDetailsW">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
color="primary"
|
||||
size="small"
|
||||
className="!ml-0"
|
||||
type="button"
|
||||
onClick={() => patchApplicationNote()}
|
||||
disabled={
|
||||
// disable if there is no change to the note, or it's updating
|
||||
applicationNoteFormValues ===
|
||||
(applicationNote || '') ||
|
||||
patchApplicationMutation.isLoading
|
||||
}
|
||||
data-cy="k8sAppDetail-saveNoteButton"
|
||||
isLoading={patchApplicationMutation.isLoading}
|
||||
loadingText={
|
||||
applicationNote ? 'Updating' : 'Saving'
|
||||
}
|
||||
>
|
||||
{applicationNote ? 'Update' : 'Save'} note
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</DetailsTable>
|
||||
</>
|
||||
)}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
@ -2,53 +2,60 @@ import { Minimize2 } from 'lucide-react';
|
|||
|
||||
import {
|
||||
BasicTableSettings,
|
||||
createPersistedStore,
|
||||
refreshableSettings,
|
||||
RefreshableTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
|
||||
import { useRepeater } from '@@/datatables/useRepeater';
|
||||
import { TableSettingsMenu } from '@@/datatables';
|
||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { Node } from '../types';
|
||||
import { NodePlacementRowData } from '../types';
|
||||
|
||||
import { SubRow } from './PlacementsDatatableSubRow';
|
||||
import { columns } from './columns';
|
||||
|
||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
||||
|
||||
function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'node', (set) => ({
|
||||
...refreshableSettings(set),
|
||||
}));
|
||||
}
|
||||
|
||||
const storageKey = 'kubernetes.application.placements';
|
||||
const settingsStore = createStore(storageKey);
|
||||
type Props = {
|
||||
isLoading: boolean;
|
||||
dataset: NodePlacementRowData[];
|
||||
hasPlacementWarning: boolean;
|
||||
tableState: TableSettings & {
|
||||
setSearch: (value: string) => void;
|
||||
search: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function PlacementsDatatable({
|
||||
isLoading,
|
||||
dataset,
|
||||
onRefresh,
|
||||
}: {
|
||||
dataset: Node[];
|
||||
onRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
|
||||
useRepeater(tableState.autoRefreshRate, onRefresh);
|
||||
|
||||
hasPlacementWarning,
|
||||
tableState,
|
||||
}: Props) {
|
||||
return (
|
||||
<ExpandableDatatable
|
||||
getRowCanExpand={(row) => !row.original.AcceptsApplication}
|
||||
isLoading={isLoading}
|
||||
getRowCanExpand={(row) => !row.original.acceptsApplication}
|
||||
title="Placement constraints/preferences"
|
||||
titleIcon={Minimize2}
|
||||
dataset={dataset}
|
||||
settingsManager={tableState}
|
||||
getRowId={(row) => row.name}
|
||||
columns={columns}
|
||||
disableSelect
|
||||
noWidget
|
||||
description={
|
||||
hasPlacementWarning ? (
|
||||
<TextTip>
|
||||
Based on the placement rules, the application pod can't be
|
||||
scheduled on any nodes.
|
||||
</TextTip>
|
||||
) : (
|
||||
<TextTip color="blue">
|
||||
The placement table helps you understand whether or not this
|
||||
application can be deployed on a specific node.
|
||||
</TextTip>
|
||||
)
|
||||
}
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<TableSettingsMenuAutoRefresh
|
|
@ -1,13 +1,14 @@
|
|||
import clsx from 'clsx';
|
||||
import { Fragment } from 'react';
|
||||
import { Taint } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { nodeAffinityValues } from '@/kubernetes/filters/application';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
|
||||
import { Affinity, Label, Node, Taint } from '../types';
|
||||
import { Affinity, Label, NodePlacementRowData } from '../types';
|
||||
|
||||
interface SubRowProps {
|
||||
node: Node;
|
||||
node: NodePlacementRowData;
|
||||
cellCount: number;
|
||||
}
|
||||
|
||||
|
@ -20,11 +21,11 @@ export function SubRow({ node, cellCount }: SubRowProps) {
|
|||
|
||||
if (!authorized) {
|
||||
<>
|
||||
{isDefined(node.UnmetTaints) && (
|
||||
{isDefined(node.unmetTaints) && (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': node.Highlighted,
|
||||
'datatable-unhighlighted': !node.Highlighted,
|
||||
'datatable-highlighted': node.highlighted,
|
||||
'datatable-unhighlighted': !node.highlighted,
|
||||
})}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
|
@ -33,12 +34,12 @@ export function SubRow({ node, cellCount }: SubRowProps) {
|
|||
</tr>
|
||||
)}
|
||||
|
||||
{(isDefined(node.UnmatchedNodeSelectorLabels) ||
|
||||
isDefined(node.UnmatchedNodeAffinities)) && (
|
||||
{(isDefined(node.unmatchedNodeSelectorLabels) ||
|
||||
isDefined(node.unmatchedNodeAffinities)) && (
|
||||
<tr
|
||||
className={clsx({
|
||||
'datatable-highlighted': node.Highlighted,
|
||||
'datatable-unhighlighted': !node.Highlighted,
|
||||
'datatable-highlighted': node.highlighted,
|
||||
'datatable-unhighlighted': !node.highlighted,
|
||||
})}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
|
@ -51,25 +52,25 @@ export function SubRow({ node, cellCount }: SubRowProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
{isDefined(node.UnmetTaints) && (
|
||||
{isDefined(node.unmetTaints) && (
|
||||
<UnmetTaintsInfo
|
||||
taints={node.UnmetTaints}
|
||||
taints={node.unmetTaints}
|
||||
cellCount={cellCount}
|
||||
isHighlighted={node.Highlighted}
|
||||
isHighlighted={node.highlighted}
|
||||
/>
|
||||
)}
|
||||
{isDefined(node.UnmatchedNodeSelectorLabels) && (
|
||||
{isDefined(node.unmatchedNodeSelectorLabels) && (
|
||||
<UnmatchedLabelsInfo
|
||||
labels={node.UnmatchedNodeSelectorLabels}
|
||||
labels={node.unmatchedNodeSelectorLabels}
|
||||
cellCount={cellCount}
|
||||
isHighlighted={node.Highlighted}
|
||||
isHighlighted={node.highlighted}
|
||||
/>
|
||||
)}
|
||||
{isDefined(node.UnmatchedNodeAffinities) && (
|
||||
{isDefined(node.unmatchedNodeAffinities) && (
|
||||
<UnmatchedAffinitiesInfo
|
||||
affinities={node.UnmatchedNodeAffinities}
|
||||
affinities={node.unmatchedNodeAffinities}
|
||||
cellCount={cellCount}
|
||||
isHighlighted={node.Highlighted}
|
||||
isHighlighted={node.highlighted}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -97,13 +98,13 @@ function UnmetTaintsInfo({
|
|||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
key={taint.Key}
|
||||
key={taint.key}
|
||||
>
|
||||
<td colSpan={cellCount}>
|
||||
This application is missing a toleration for the taint
|
||||
<code className="space-left">
|
||||
{taint.Key}
|
||||
{taint.Value ? `=${taint.Value}` : ''}:{taint.Effect}
|
||||
{taint.key}
|
||||
{taint.value ? `=${taint.value}` : ''}:{taint.effect}
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { NodePlacementRowData } from '../../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<NodePlacementRowData>();
|
|
@ -1,14 +1,14 @@
|
|||
import { buildExpandColumn } from '@@/datatables/expand-column';
|
||||
|
||||
import { Node } from '../../types';
|
||||
import { NodePlacementRowData } from '../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
import { status } from './status';
|
||||
|
||||
export const columns = [
|
||||
buildExpandColumn<Node>(),
|
||||
buildExpandColumn<NodePlacementRowData>(),
|
||||
status,
|
||||
columnHelper.accessor('Name', {
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Node',
|
||||
id: 'node',
|
||||
}),
|
|
@ -4,7 +4,7 @@ import { Icon } from '@@/Icon';
|
|||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const status = columnHelper.accessor('AcceptsApplication', {
|
||||
export const status = columnHelper.accessor('acceptsApplication', {
|
||||
header: '',
|
||||
id: 'status',
|
||||
enableSorting: false,
|
|
@ -0,0 +1,5 @@
|
|||
export { PlacementsDatatable } from './PlacementsDatatable';
|
||||
export {
|
||||
usePlacementTableState,
|
||||
usePlacementTableData,
|
||||
} from './usePlacementTableData';
|
|
@ -0,0 +1,249 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
import { Pod, Taint, Node } from 'kubernetes-types/core/v1';
|
||||
import _ from 'lodash';
|
||||
import * as JsonPatch from 'fast-json-patch';
|
||||
|
||||
import { useNodesQuery } from '@/react/kubernetes/cluster/HomeView/nodes.service';
|
||||
|
||||
import {
|
||||
BasicTableSettings,
|
||||
RefreshableTableSettings,
|
||||
createPersistedStore,
|
||||
refreshableSettings,
|
||||
} from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useApplication, useApplicationPods } from '../../application.queries';
|
||||
import { NodePlacementRowData } from '../types';
|
||||
|
||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
||||
|
||||
function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'node', (set) => ({
|
||||
...refreshableSettings(set),
|
||||
}));
|
||||
}
|
||||
|
||||
const storageKey = 'kubernetes.application.placements';
|
||||
const placementsSettingsStore = createStore(storageKey);
|
||||
|
||||
export function usePlacementTableState() {
|
||||
return useTableState(placementsSettingsStore, storageKey);
|
||||
}
|
||||
|
||||
export function usePlacementTableData() {
|
||||
const placementsTableState = usePlacementTableState();
|
||||
const autoRefreshRate = placementsTableState.autoRefreshRate * 1000; // ms to seconds
|
||||
|
||||
const stateAndParams = useCurrentStateAndParams();
|
||||
const {
|
||||
params: {
|
||||
namespace,
|
||||
name,
|
||||
'resource-type': resourceType,
|
||||
endpointId: environmentId,
|
||||
},
|
||||
} = stateAndParams;
|
||||
const { data: application, ...applicationQuery } = useApplication(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
resourceType,
|
||||
{ autoRefreshRate }
|
||||
);
|
||||
const { data: pods, ...podsQuery } = useApplicationPods(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
application,
|
||||
{ autoRefreshRate }
|
||||
);
|
||||
const { data: nodes, ...nodesQuery } = useNodesQuery(environmentId, {
|
||||
autoRefreshRate,
|
||||
});
|
||||
|
||||
const placementsData = useMemo(
|
||||
() => (nodes && pods ? computePlacements(nodes, pods) : []),
|
||||
[nodes, pods]
|
||||
);
|
||||
|
||||
const isPlacementsTableLoading =
|
||||
applicationQuery.isLoading || nodesQuery.isLoading || podsQuery.isLoading;
|
||||
|
||||
const hasPlacementWarning = useMemo(() => {
|
||||
const notAllowedOnEveryNode = placementsData.every(
|
||||
(nodePlacement) => !nodePlacement.acceptsApplication
|
||||
);
|
||||
return !isPlacementsTableLoading && notAllowedOnEveryNode;
|
||||
}, [isPlacementsTableLoading, placementsData]);
|
||||
|
||||
return {
|
||||
placementsData,
|
||||
isPlacementsTableLoading,
|
||||
hasPlacementWarning,
|
||||
};
|
||||
}
|
||||
|
||||
export function computePlacements(
|
||||
nodes: Node[],
|
||||
pods: Pod[]
|
||||
): NodePlacementRowData[] {
|
||||
const pod = pods?.[0];
|
||||
if (!pod) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const placementDataFromTolerations: NodePlacementRowData[] =
|
||||
computeTolerations(nodes, pod);
|
||||
const placementDataFromAffinities: NodePlacementRowData[] = computeAffinities(
|
||||
nodes,
|
||||
placementDataFromTolerations,
|
||||
pod
|
||||
);
|
||||
return placementDataFromAffinities;
|
||||
}
|
||||
|
||||
function computeTolerations(nodes: Node[], pod: Pod): NodePlacementRowData[] {
|
||||
const tolerations = pod.spec?.tolerations || [];
|
||||
const nodePlacements: NodePlacementRowData[] = nodes.map((node) => {
|
||||
let acceptsApplication = true;
|
||||
const unmetTaints: Taint[] = [];
|
||||
const taints = node.spec?.taints || [];
|
||||
taints.forEach((taint) => {
|
||||
const matchKeyMatchValueMatchEffect = _.find(tolerations, {
|
||||
key: taint.key,
|
||||
operator: 'Equal',
|
||||
value: taint.value,
|
||||
effect: taint.effect,
|
||||
});
|
||||
const matchKeyAnyValueMatchEffect = _.find(tolerations, {
|
||||
key: taint.key,
|
||||
operator: 'Exists',
|
||||
effect: taint.effect,
|
||||
});
|
||||
const matchKeyMatchValueAnyEffect = _.find(tolerations, {
|
||||
key: taint.key,
|
||||
operator: 'Equal',
|
||||
value: taint.value,
|
||||
effect: '',
|
||||
});
|
||||
const matchKeyAnyValueAnyEffect = _.find(tolerations, {
|
||||
key: taint.key,
|
||||
operator: 'Exists',
|
||||
effect: '',
|
||||
});
|
||||
const anyKeyAnyValueAnyEffect = _.find(tolerations, {
|
||||
key: '',
|
||||
operator: 'Exists',
|
||||
effect: '',
|
||||
});
|
||||
if (
|
||||
!matchKeyMatchValueMatchEffect &&
|
||||
!matchKeyAnyValueMatchEffect &&
|
||||
!matchKeyMatchValueAnyEffect &&
|
||||
!matchKeyAnyValueAnyEffect &&
|
||||
!anyKeyAnyValueAnyEffect
|
||||
) {
|
||||
acceptsApplication = false;
|
||||
unmetTaints?.push(taint);
|
||||
} else {
|
||||
acceptsApplication = true;
|
||||
}
|
||||
});
|
||||
return {
|
||||
name: node.metadata?.name || '',
|
||||
acceptsApplication,
|
||||
unmetTaints,
|
||||
highlighted: false,
|
||||
};
|
||||
});
|
||||
|
||||
return nodePlacements;
|
||||
}
|
||||
|
||||
// Node requirement depending on the operator value
|
||||
// https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity
|
||||
function computeAffinities(
|
||||
nodes: Node[],
|
||||
nodePlacements: NodePlacementRowData[],
|
||||
pod: Pod
|
||||
): NodePlacementRowData[] {
|
||||
const nodePlacementsFromAffinities: NodePlacementRowData[] = nodes.map(
|
||||
(node, nodeIndex) => {
|
||||
let { acceptsApplication } = nodePlacements[nodeIndex];
|
||||
|
||||
if (pod.spec?.nodeSelector) {
|
||||
const patch = JsonPatch.compare(
|
||||
node.metadata?.labels || {},
|
||||
pod.spec.nodeSelector
|
||||
);
|
||||
_.remove(patch, { op: 'remove' });
|
||||
const unmatchedNodeSelectorLabels = patch.map((operation) => ({
|
||||
key: _.trimStart(operation.path, '/'),
|
||||
value: operation.op,
|
||||
}));
|
||||
if (unmatchedNodeSelectorLabels.length) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
}
|
||||
|
||||
const basicNodeAffinity =
|
||||
pod.spec?.affinity?.nodeAffinity
|
||||
?.requiredDuringSchedulingIgnoredDuringExecution;
|
||||
if (basicNodeAffinity) {
|
||||
const unmatchedTerms = basicNodeAffinity.nodeSelectorTerms.map(
|
||||
(selectorTerm) => {
|
||||
const unmatchedExpressions = selectorTerm.matchExpressions?.flatMap(
|
||||
(matchExpression) => {
|
||||
const exists = {}.hasOwnProperty.call(
|
||||
node.metadata?.labels,
|
||||
matchExpression.key
|
||||
);
|
||||
const isIn =
|
||||
exists &&
|
||||
_.includes(
|
||||
matchExpression.values,
|
||||
node.metadata?.labels?.[matchExpression.key]
|
||||
);
|
||||
if (
|
||||
(matchExpression.operator === 'Exists' && exists) ||
|
||||
(matchExpression.operator === 'DoesNotExist' && !exists) ||
|
||||
(matchExpression.operator === 'In' && isIn) ||
|
||||
(matchExpression.operator === 'NotIn' && !isIn) ||
|
||||
(matchExpression.operator === 'Gt' &&
|
||||
exists &&
|
||||
parseInt(
|
||||
node.metadata?.labels?.[matchExpression.key] || '',
|
||||
10
|
||||
) > parseInt(matchExpression.values?.[0] || '', 10)) ||
|
||||
(matchExpression.operator === 'Lt' &&
|
||||
exists &&
|
||||
parseInt(
|
||||
node.metadata?.labels?.[matchExpression.key] || '',
|
||||
10
|
||||
) < parseInt(matchExpression.values?.[0] || '', 10))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [true];
|
||||
}
|
||||
);
|
||||
|
||||
return unmatchedExpressions;
|
||||
}
|
||||
);
|
||||
|
||||
_.remove(unmatchedTerms, (i) => i?.length === 0);
|
||||
if (unmatchedTerms.length) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...nodePlacements[nodeIndex],
|
||||
acceptsApplication,
|
||||
};
|
||||
}
|
||||
);
|
||||
return nodePlacementsFromAffinities;
|
||||
}
|
|
@ -1,10 +1,6 @@
|
|||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '@/kubernetes/pod/models';
|
||||
import { Taint } from 'kubernetes-types/core/v1';
|
||||
|
||||
export interface Taint {
|
||||
Key: string;
|
||||
Value?: string;
|
||||
Effect: string;
|
||||
}
|
||||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '@/kubernetes/pod/models';
|
||||
|
||||
export interface Label {
|
||||
key: string;
|
||||
|
@ -19,11 +15,11 @@ interface AffinityTerm {
|
|||
|
||||
export type Affinity = Array<AffinityTerm>;
|
||||
|
||||
export type Node = {
|
||||
Name: string;
|
||||
AcceptsApplication: boolean;
|
||||
UnmetTaints?: Array<Taint>;
|
||||
UnmatchedNodeSelectorLabels?: Array<Label>;
|
||||
Highlighted: boolean;
|
||||
UnmatchedNodeAffinities?: Array<Affinity>;
|
||||
export type NodePlacementRowData = {
|
||||
name: string;
|
||||
acceptsApplication: boolean;
|
||||
unmetTaints?: Array<Taint>;
|
||||
unmatchedNodeSelectorLabels?: Array<Label>;
|
||||
highlighted: boolean;
|
||||
unmatchedNodeAffinities?: Array<Affinity>;
|
||||
};
|
|
@ -0,0 +1,88 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import {
|
||||
useApplication,
|
||||
useApplicationPods,
|
||||
useApplicationServices,
|
||||
} from '../application.queries';
|
||||
|
||||
import { useNamespaceEventsQuery } from './useNamespaceEventsQuery';
|
||||
|
||||
const storageKey = 'k8sAppEventsDatatable';
|
||||
const settingsStore = createStore(storageKey, { id: 'Date', desc: true });
|
||||
|
||||
export function useApplicationEventsTableState() {
|
||||
return useTableState(settingsStore, storageKey);
|
||||
}
|
||||
|
||||
export function useApplicationEventsTableData() {
|
||||
const appEventsTableState = useTableState(settingsStore, storageKey);
|
||||
const {
|
||||
params: {
|
||||
namespace,
|
||||
name,
|
||||
'resource-type': resourceType,
|
||||
endpointId: environmentId,
|
||||
},
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const { data: application, ...applicationQuery } = useApplication(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
resourceType
|
||||
);
|
||||
const { data: services, ...servicesQuery } = useApplicationServices(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
application
|
||||
);
|
||||
const { data: pods, ...podsQuery } = useApplicationPods(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
application
|
||||
);
|
||||
const { data: events, ...eventsQuery } = useNamespaceEventsQuery(
|
||||
environmentId,
|
||||
namespace,
|
||||
{
|
||||
autoRefreshRate: appEventsTableState.autoRefreshRate * 1000,
|
||||
}
|
||||
);
|
||||
|
||||
// related events are events that have the application id, or the id of a service or pod from the application
|
||||
const appEventsData = useMemo(() => {
|
||||
const serviceIds = services?.map((service) => service?.metadata?.uid);
|
||||
const podIds = pods?.map((pod) => pod?.metadata?.uid);
|
||||
return (
|
||||
events?.filter(
|
||||
(event) =>
|
||||
event.involvedObject.uid === application?.metadata?.uid ||
|
||||
serviceIds?.includes(event.involvedObject.uid) ||
|
||||
podIds?.includes(event.involvedObject.uid)
|
||||
) || []
|
||||
);
|
||||
}, [application?.metadata?.uid, events, pods, services]);
|
||||
|
||||
const appEventWarningCount = useMemo(
|
||||
() => appEventsData.filter((event) => event.type === 'Warning').length,
|
||||
[appEventsData]
|
||||
);
|
||||
|
||||
return {
|
||||
appEventsData,
|
||||
appEventWarningCount,
|
||||
isAppEventsTableLoading:
|
||||
applicationQuery.isLoading ||
|
||||
servicesQuery.isLoading ||
|
||||
podsQuery.isLoading ||
|
||||
eventsQuery.isLoading,
|
||||
};
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Node } from '../../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<Node>();
|
|
@ -1 +0,0 @@
|
|||
export { PlacementsDatatable } from './PlacementsDatatable';
|
|
@ -1,4 +1,4 @@
|
|||
import { useMutation, useQuery } from 'react-query';
|
||||
import { UseQueryResult, useMutation, useQuery } from 'react-query';
|
||||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { queryClient, withError } from '@/react-tools/react-query';
|
||||
|
@ -27,7 +27,8 @@ const queryKeys = {
|
|||
application: (
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
name: string,
|
||||
yaml?: boolean
|
||||
) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
|
@ -35,6 +36,7 @@ const queryKeys = {
|
|||
'applications',
|
||||
namespace,
|
||||
name,
|
||||
yaml,
|
||||
],
|
||||
applicationRevisions: (
|
||||
environmentId: EnvironmentId,
|
||||
|
@ -120,18 +122,23 @@ export function useApplicationsForCluster(
|
|||
);
|
||||
}
|
||||
|
||||
// useQuery to get an application by environmentId, namespace and name
|
||||
export function useApplication(
|
||||
// when yaml is set to true, the expected return type is a string
|
||||
export function useApplication<T extends Application | string = Application>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string,
|
||||
appKind?: AppKind
|
||||
) {
|
||||
appKind?: AppKind,
|
||||
options?: { autoRefreshRate?: number; yaml?: boolean }
|
||||
): UseQueryResult<T> {
|
||||
return useQuery(
|
||||
queryKeys.application(environmentId, namespace, name),
|
||||
() => getApplication(environmentId, namespace, name, appKind),
|
||||
queryKeys.application(environmentId, namespace, name, options?.yaml),
|
||||
() =>
|
||||
getApplication<T>(environmentId, namespace, name, appKind, options?.yaml),
|
||||
{
|
||||
...withError('Unable to retrieve application'),
|
||||
refetchInterval() {
|
||||
return options?.autoRefreshRate ?? false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -212,7 +219,7 @@ export function useApplicationServices(
|
|||
}
|
||||
|
||||
// useApplicationHorizontalPodAutoscalers returns a query for horizontal pod autoscalers that are related to the application
|
||||
export function useApplicationHorizontalPodAutoscalers(
|
||||
export function useApplicationHorizontalPodAutoscaler(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appName: string,
|
||||
|
@ -231,7 +238,7 @@ export function useApplicationHorizontalPodAutoscalers(
|
|||
|
||||
const horizontalPodAutoscalers =
|
||||
await getNamespaceHorizontalPodAutoscalers(environmentId, namespace);
|
||||
const filteredHorizontalPodAutoscalers =
|
||||
const matchingHorizontalPodAutoscaler =
|
||||
horizontalPodAutoscalers.find((horizontalPodAutoscaler) => {
|
||||
const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef;
|
||||
if (scaleTargetRef) {
|
||||
|
@ -245,11 +252,11 @@ export function useApplicationHorizontalPodAutoscalers(
|
|||
}
|
||||
return false;
|
||||
}) || null;
|
||||
return filteredHorizontalPodAutoscalers;
|
||||
return matchingHorizontalPodAutoscaler;
|
||||
},
|
||||
{
|
||||
...withError(
|
||||
`Unable to get horizontal pod autoscalers${
|
||||
`Unable to get horizontal pod autoscaler${
|
||||
app ? ` for ${app.metadata?.name}` : ''
|
||||
}`
|
||||
),
|
||||
|
@ -263,7 +270,8 @@ export function useApplicationPods(
|
|||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appName: string,
|
||||
app?: Application
|
||||
app?: Application,
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.applicationPods(environmentId, namespace, appName),
|
||||
|
@ -287,6 +295,9 @@ export function useApplicationPods(
|
|||
{
|
||||
...withError(`Unable to get pods for ${appName}`),
|
||||
enabled: !!app,
|
||||
refetchInterval() {
|
||||
return options?.autoRefreshRate ?? false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -83,11 +83,14 @@ async function getApplicationsForNamespace(
|
|||
}
|
||||
|
||||
// if not known, get the type of an application (Deployment, DaemonSet, StatefulSet or naked pod) by name
|
||||
export async function getApplication(
|
||||
export async function getApplication<
|
||||
T extends Application | string = Application
|
||||
>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string,
|
||||
appKind?: AppKind
|
||||
appKind?: AppKind,
|
||||
yaml?: boolean
|
||||
) {
|
||||
try {
|
||||
// if resourceType is known, get the application by type and name
|
||||
|
@ -96,14 +99,15 @@ export async function getApplication(
|
|||
case 'Deployment':
|
||||
case 'DaemonSet':
|
||||
case 'StatefulSet':
|
||||
return await getApplicationByKind(
|
||||
return await getApplicationByKind<T>(
|
||||
environmentId,
|
||||
namespace,
|
||||
appKind,
|
||||
name
|
||||
name,
|
||||
yaml
|
||||
);
|
||||
case 'Pod':
|
||||
return await getPod(environmentId, namespace, name);
|
||||
return await getPod(environmentId, namespace, name, yaml);
|
||||
default:
|
||||
throw new Error('Unknown resource type');
|
||||
}
|
||||
|
@ -115,21 +119,24 @@ export async function getApplication(
|
|||
environmentId,
|
||||
namespace,
|
||||
'Deployment',
|
||||
name
|
||||
name,
|
||||
yaml
|
||||
),
|
||||
getApplicationByKind<DaemonSet>(
|
||||
environmentId,
|
||||
namespace,
|
||||
'DaemonSet',
|
||||
name
|
||||
name,
|
||||
yaml
|
||||
),
|
||||
getApplicationByKind<StatefulSet>(
|
||||
environmentId,
|
||||
namespace,
|
||||
'StatefulSet',
|
||||
name
|
||||
name,
|
||||
yaml
|
||||
),
|
||||
getPod(environmentId, namespace, name),
|
||||
getPod(environmentId, namespace, name, yaml),
|
||||
]);
|
||||
|
||||
if (isFulfilled(deployment)) {
|
||||
|
@ -225,15 +232,21 @@ async function patchApplicationByKind<T extends Application>(
|
|||
}
|
||||
}
|
||||
|
||||
async function getApplicationByKind<T extends Application>(
|
||||
async function getApplicationByKind<
|
||||
T extends Application | string = Application
|
||||
>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
|
||||
name: string
|
||||
name: string,
|
||||
yaml?: boolean
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<T>(
|
||||
buildUrl(environmentId, namespace, `${appKind}s`, name)
|
||||
buildUrl(environmentId, namespace, `${appKind}s`, name),
|
||||
{
|
||||
headers: { Accept: yaml ? 'application/yaml' : 'application/json' },
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
|
|
|
@ -1,14 +1,88 @@
|
|||
import { HorizontalPodAutoscalerList } from 'kubernetes-types/autoscaling/v1';
|
||||
import {
|
||||
HorizontalPodAutoscaler,
|
||||
HorizontalPodAutoscalerList,
|
||||
} from 'kubernetes-types/autoscaling/v1';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { parseKubernetesAxiosError } from '../axiosError';
|
||||
|
||||
// when yaml is set to true, the expected return type is a string
|
||||
export function useHorizontalAutoScalarQuery<
|
||||
T extends HorizontalPodAutoscaler | string = HorizontalPodAutoscaler
|
||||
>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name?: string,
|
||||
options?: { yaml?: boolean }
|
||||
) {
|
||||
return useQuery(
|
||||
[
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'namespaces',
|
||||
namespace,
|
||||
'horizontalpodautoscalers',
|
||||
name,
|
||||
options?.yaml,
|
||||
],
|
||||
() =>
|
||||
name
|
||||
? getNamespaceHorizontalPodAutoscaler<T>(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
options
|
||||
)
|
||||
: undefined,
|
||||
{ ...withError('Unable to get horizontal pod autoscaler'), enabled: !!name }
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
const { data: autoScalarList } =
|
||||
await axios.get<HorizontalPodAutoscalerList>(
|
||||
`/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers`
|
||||
);
|
||||
return autoScalarList.items;
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve horizontal pod autoscalers'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNamespaceHorizontalPodAutoscaler<
|
||||
T extends HorizontalPodAutoscaler | string = HorizontalPodAutoscaler
|
||||
>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string,
|
||||
options?: { yaml?: boolean }
|
||||
) {
|
||||
try {
|
||||
const { data: autoScalar } = await axios.get<T>(
|
||||
`/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers/${name}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: options?.yaml ? 'application/yaml' : 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
return autoScalar;
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve horizontal pod autoscaler'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,14 +25,18 @@ export async function getNamespacePods(
|
|||
}
|
||||
}
|
||||
|
||||
export async function getPod(
|
||||
export async function getPod<T extends Pod | string = Pod>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
name: string,
|
||||
yaml?: boolean
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<Pod>(
|
||||
buildUrl(environmentId, namespace, name)
|
||||
const { data } = await axios.get<T>(
|
||||
buildUrl(environmentId, namespace, name),
|
||||
{
|
||||
headers: { Accept: yaml ? 'application/yaml' : 'application/json' },
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue