feat(app): migrate app parent view to react [EE-5361] (#10086)

Co-authored-by: testa113 <testa113>
pull/10191/head
Ali 1 year ago committed by GitHub
parent 531f88b947
commit 841ca1ebd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -151,10 +151,10 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
const application = {
name: 'kubernetes.applications.application',
url: '/:namespace/:name?resource-type',
url: '/:namespace/:name?resource-type&tab',
views: {
'content@': {
component: 'kubernetesApplicationView',
component: 'applicationDetailsView',
},
},
};

@ -20,7 +20,6 @@ import {
import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable';
import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { PlacementsDatatable } from '@/react/kubernetes/applications/ItemView/PlacementsDatatable';
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
export const ngModule = angular
@ -102,6 +101,7 @@ export const ngModule = angular
r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [
'identifier',
'data',
'hideMessage',
])
)
.component(
@ -133,13 +133,6 @@ export const ngModule = angular
withUIRouter(withReactQuery(withCurrentUser(ApplicationEventsDatatable))),
[]
)
)
.component(
'kubernetesApplicationPlacementsDatatable',
r2a(withUIRouter(withCurrentUser(PlacementsDatatable)), [
'dataset',
'onRefresh',
])
);
export const componentsModule = ngModule.name;

@ -10,6 +10,7 @@ import { DashboardView } from '@/react/kubernetes/dashboard/DashboardView';
import { ServicesView } from '@/react/kubernetes/services/ServicesView';
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@ -35,6 +36,13 @@ export const viewsModule = angular
[]
)
)
.component(
'applicationDetailsView',
r2a(
withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsView))),
[]
)
)
.component(
'kubernetesDashboardView',
r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), [])

@ -1,83 +0,0 @@
<page-header
ng-if="ctrl.state.viewReady"
title="'Application details'"
breadcrumbs="[
{ label:'Namespaces', link:'kubernetes.resourcePools' },
{
label:ctrl.application.ResourcePool,
link: 'kubernetes.resourcePools.resourcePool',
linkParams:{ id: ctrl.application.ResourcePool }
},
{ label:'Applications', link:'kubernetes.applications' },
ctrl.application.Name
]"
reload="true"
>
</page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<div class="row kubernetes-application">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body classes="no-padding">
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
<uib-tab-heading> <pr-icon icon="'svg-laptopcode'" class-name="'mr-1'"></pr-icon> Application </uib-tab-heading>
<application-summary-widget></application-summary-widget>
</uib-tab>
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
<uib-tab-heading>
<pr-icon icon="'minimize-2'"></pr-icon> Placement
<div ng-if="ctrl.state.placementWarning" class="vertical-center">
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
warning
</div>
</uib-tab-heading>
<div class="small text-muted vertical-center" style="padding: 20px">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
The placement component helps you understand whether or not this application can be deployed on a specific node.
</div>
<kubernetes-application-placements-datatable
ng-if="ctrl.placements"
dataset="ctrl.placements"
on-refresh="(ctrl.getApplication)"
></kubernetes-application-placements-datatable>
</uib-tab>
<uib-tab index="2" classes="btn-sm" select="ctrl.selectTab(2)">
<uib-tab-heading>
<pr-icon icon="'history'"></pr-icon> Events
<div ng-if="ctrl.hasEventWarnings()" class="vertical-center">
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
{{ ctrl.state.eventWarningCount }} warning(s)
</div>
</uib-tab-heading>
<application-events-datatable />
</uib-tab>
<uib-tab index="3" ng-if="ctrl.application.Yaml" select="ctrl.showEditor()" classes="btn-sm">
<uib-tab-heading> <pr-icon icon="'code'"></pr-icon> YAML </uib-tab-heading>
<div class="px-5" ng-if="ctrl.state.showEditorTab">
<kube-yaml-inspector identifier="'application-yaml'" data="ctrl.application.Yaml" />
</div>
</uib-tab>
</uib-tabset>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<application-details-widget></application-details-widget>
</div>
</div>
<div class="row">
<application-containers-datatable />
</div>
</div>

@ -1,9 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesApplicationView', {
templateUrl: './application.html',
controller: 'KubernetesApplicationController',
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
endpoint: '<',
},
});

@ -1,266 +0,0 @@
import angular from 'angular';
import _ from 'lodash-es';
import * as JsonPatch from 'fast-json-patch';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import {
KubernetesApplicationDataAccessPolicies,
KubernetesApplicationDeploymentTypes,
KubernetesApplicationTypes,
KubernetesDeploymentTypes,
} from 'Kubernetes/models/application/models';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
import { KubernetesPodContainerTypes } from 'Kubernetes/pod/models/index';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
function computeTolerations(nodes, application) {
const pod = application.Pods[0];
_.forEach(nodes, (n) => {
n.AcceptsApplication = true;
n.Expanded = false;
if (!pod) {
return;
}
n.UnmetTaints = [];
_.forEach(n.Taints, (t) => {
const matchKeyMatchValueMatchEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Equal', Value: t.Value, Effect: t.Effect });
const matchKeyAnyValueMatchEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Exists', Effect: t.Effect });
const matchKeyMatchValueAnyEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Equal', Value: t.Value, Effect: '' });
const matchKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Exists', Effect: '' });
const anyKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: '', Operator: 'Exists', Effect: '' });
if (!matchKeyMatchValueMatchEffect && !matchKeyAnyValueMatchEffect && !matchKeyMatchValueAnyEffect && !matchKeyAnyValueAnyEffect && !anyKeyAnyValueAnyEffect) {
n.AcceptsApplication = false;
n.UnmetTaints.push(t);
} else {
n.AcceptsApplication = true;
}
});
});
return nodes;
}
// For node requirement format depending on operator value
// see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#nodeselectorrequirement-v1-core
// Some operators require empty "values" field, some only one element in "values" field, etc
function computeAffinities(nodes, application) {
if (!application.Pods || application.Pods.length === 0) {
return nodes;
}
const pod = application.Pods[0];
_.forEach(nodes, (n) => {
if (pod.NodeSelector) {
const patch = JsonPatch.compare(n.Labels, pod.NodeSelector);
_.remove(patch, { op: 'remove' });
n.UnmatchedNodeSelectorLabels = _.map(patch, (i) => {
return { key: _.trimStart(i.path, '/'), value: i.value };
});
if (n.UnmatchedNodeSelectorLabels.length) {
n.AcceptsApplication = false;
}
}
if (pod.Affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution) {
const unmatchedTerms = _.map(pod.Affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms, (t) => {
const unmatchedExpressions = _.map(t.matchExpressions, (e) => {
const exists = {}.hasOwnProperty.call(n.Labels, e.key);
const isIn = exists && _.includes(e.values, n.Labels[e.key]);
if (
(e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS && exists) ||
(e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.DOES_NOT_EXIST && !exists) ||
(e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN && isIn) ||
(e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.NOT_IN && !isIn) ||
(e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.GREATER_THAN && exists && parseInt(n.Labels[e.key], 10) > parseInt(e.values[0], 10)) ||
(e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.LOWER_THAN && exists && parseInt(n.Labels[e.key], 10) < parseInt(e.values[0], 10))
) {
return;
}
return e;
});
return _.without(unmatchedExpressions, undefined);
});
_.remove(unmatchedTerms, (i) => i.length === 0);
n.UnmatchedNodeAffinities = unmatchedTerms;
if (n.UnmatchedNodeAffinities.length) {
n.AcceptsApplication = false;
}
}
});
return nodes;
}
function computePlacements(nodes, application) {
nodes = computeTolerations(nodes, application);
nodes = computeAffinities(nodes, application);
return nodes;
}
class KubernetesApplicationController {
/* @ngInject */
constructor(
$async,
$state,
clipboard,
Notifications,
LocalStorage,
KubernetesResourcePoolService,
KubernetesApplicationService,
KubernetesEventService,
KubernetesStackService,
KubernetesPodService,
KubernetesNodeService,
StackService
) {
this.$async = $async;
this.$state = $state;
this.clipboard = clipboard;
this.Notifications = Notifications;
this.LocalStorage = LocalStorage;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.StackService = StackService;
this.KubernetesApplicationService = KubernetesApplicationService;
this.KubernetesEventService = KubernetesEventService;
this.KubernetesStackService = KubernetesStackService;
this.KubernetesPodService = KubernetesPodService;
this.KubernetesNodeService = KubernetesNodeService;
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
this.KubernetesApplicationTypes = KubernetesApplicationTypes;
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
this.KubernetesServiceTypes = KubernetesServiceTypes;
this.KubernetesPodContainerTypes = KubernetesPodContainerTypes;
this.onInit = this.onInit.bind(this);
this.getApplication = this.getApplication.bind(this);
this.getApplicationAsync = this.getApplicationAsync.bind(this);
this.getEvents = this.getEvents.bind(this);
this.getEventsAsync = this.getEventsAsync.bind(this);
}
selectTab(index) {
this.LocalStorage.storeActiveTab('application', index);
}
showEditor() {
this.state.showEditorTab = true;
this.selectTab(3);
}
isSystemNamespace() {
return KubernetesNamespaceHelper.isSystemNamespace(this.application.ResourcePool);
}
hasEventWarnings() {
return this.state.eventWarningCount;
}
/**
* EVENTS
*/
async getEventsAsync() {
try {
this.state.eventsLoading = true;
const events = await this.KubernetesEventService.get(this.state.params.namespace);
this.events = _.filter(
events,
(event) =>
event.Involved.uid === this.application.Id ||
event.Involved.uid === this.application.ServiceId ||
_.find(this.application.Pods, (pod) => pod.Id === event.Involved.uid) !== undefined
);
this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve application related events');
} finally {
this.state.eventsLoading = false;
}
}
getEvents() {
return this.$async(this.getEventsAsync);
}
/**
* APPLICATION
*/
async getApplicationAsync() {
try {
this.state.dataLoading = true;
const [application, nodes] = await Promise.all([
this.KubernetesApplicationService.get(this.state.params.namespace, this.state.params.name),
this.KubernetesNodeService.get(),
]);
this.application = application;
this.placements = computePlacements(nodes, this.application);
this.state.placementWarning = _.find(this.placements, { AcceptsApplication: true }) ? false : true;
if (application.StackId) {
const file = await this.StackService.getStackFile(application.StackId);
this.stackFileContent = file;
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve application details');
} finally {
this.state.dataLoading = false;
}
}
getApplication() {
return this.$async(this.getApplicationAsync);
}
async onInit() {
this.limitedFeature = FeatureId.K8S_ROLLING_RESTART;
this.state = {
activeTab: 0,
currentName: this.$state.$current.name,
showEditorTab: false,
DisplayedPanel: 'pods',
eventsLoading: true,
dataLoading: true,
viewReady: false,
params: {
namespace: this.$transition$.params().namespace,
name: this.$transition$.params().name,
},
appType: this.KubernetesDeploymentTypes.APPLICATION_FORM,
eventWarningCount: 0,
placementWarning: false,
expandedNote: false,
publicUrl: this.endpoint.PublicURL,
};
this.state.activeTab = this.LocalStorage.getActiveTab('application');
this.formValues = {
Note: '',
SelectedRevision: undefined,
};
await this.getApplication();
await this.getEvents();
this.state.viewReady = true;
}
$onInit() {
return this.$async(this.onInit);
}
$onDestroy() {
if (this.state.currentName !== this.$state.$current.name) {
this.LocalStorage.storeActiveTab('application', 0);
}
}
}
export default KubernetesApplicationController;
angular.module('portainer.kubernetes').controller('KubernetesApplicationController', KubernetesApplicationController);

@ -121,7 +121,10 @@ export const ngModule = angular
r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className'])
)
.component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin']))
.component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, []))
.component(
'reactQueryDevTools',
r2a(withReactQuery(ReactQueryDevtoolsWrapper), [])
)
.component(
'dashboardItem',
r2a(DashboardItem, [

@ -1,6 +1,6 @@
import { Meta } from '@storybook/react';
import { Badge, Props } from './Badge';
import { Badge, BadgeType, Props } from './Badge';
export default {
component: Badge,
@ -17,11 +17,15 @@ export default {
// : JSX.IntrinsicAttributes & PropsWithChildren<Props>
function Template({ type = 'success' }: Props) {
const message = {
const message: Record<BadgeType, string> = {
success: 'success badge',
danger: 'danger badge',
warn: 'warn badge',
info: 'info badge',
successSecondary: 'successSecondary badge',
dangerSecondary: 'dangerSecondary badge',
warnSecondary: 'warnSecondary badge',
infoSecondary: 'infoSecondary badge',
};
return <Badge type={type}>{message[type]}</Badge>;
}

@ -1,7 +1,60 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
export type BadgeType = 'success' | 'danger' | 'warn' | 'info';
export type BadgeType =
| 'success'
| 'danger'
| 'warn'
| 'info'
| 'successSecondary'
| 'dangerSecondary'
| 'warnSecondary'
| 'infoSecondary';
// the classes are typed in full because tailwind doesn't render the interpolated classes
const typeClasses: Record<BadgeType, string> = {
success: clsx(
`text-success-9 bg-success-2`,
`th-dark:text-success-3 th-dark:bg-success-10`,
`th-highcontrast:text-success-3 th-highcontrast:bg-success-10`
),
warn: clsx(
`text-warning-9 bg-warning-2`,
`th-dark:text-warning-3 th-dark:bg-warning-10`,
`th-highcontrast:text-warning-3 th-highcontrast:bg-warning-10`
),
danger: clsx(
`text-error-9 bg-error-2`,
`th-dark:text-error-3 th-dark:bg-error-10`,
`th-highcontrast:text-error-3 th-highcontrast:bg-error-10`
),
info: clsx(
`text-blue-9 bg-blue-2`,
`th-dark:text-blue-3 th-dark:bg-blue-10`,
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
),
// the secondary classes are a bit darker in light mode and a bit lighter in dark mode
successSecondary: clsx(
`text-success-9 bg-success-3`,
`th-dark:text-success-3 th-dark:bg-success-9`,
`th-highcontrast:text-success-3 th-highcontrast:bg-success-9`
),
warnSecondary: clsx(
`text-warning-9 bg-warning-3`,
`th-dark:text-warning-3 th-dark:bg-warning-9`,
`th-highcontrast:text-warning-3 th-highcontrast:bg-warning-9`
),
dangerSecondary: clsx(
`text-error-9 bg-error-3`,
`th-dark:text-error-3 th-dark:bg-error-9`,
`th-highcontrast:text-error-3 th-highcontrast:bg-error-9`
),
infoSecondary: clsx(
`text-blue-9 bg-blue-3`,
`th-dark:text-blue-3 th-dark:bg-blue-9`,
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-9`
),
};
export interface Props {
type?: BadgeType;
@ -10,50 +63,17 @@ export interface Props {
// this component is used in tables and lists in portainer. It looks like this:
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?node-id=76%3A2
export function Badge({ type, className, children }: PropsWithChildren<Props>) {
export function Badge({
type = 'info',
className,
children,
}: PropsWithChildren<Props>) {
const baseClasses =
'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
const typeClasses = getClasses(type);
return (
<span className={clsx(baseClasses, typeClasses, className)}>
<span className={clsx(baseClasses, typeClasses[type], className)}>
{children}
</span>
);
}
// the classes are typed in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes
function getClasses(type: BadgeType | undefined) {
switch (type) {
case 'success':
return clsx(
`text-success-9 bg-success-2`,
`th-dark:text-success-3 th-dark:bg-success-10`,
`th-highcontrast:text-success-3 th-highcontrast:bg-success-10`
);
case 'warn':
return clsx(
`text-warning-9 bg-warning-2`,
`th-dark:text-warning-3 th-dark:bg-warning-10`,
`th-highcontrast:text-warning-3 th-highcontrast:bg-warning-10`
);
case 'danger':
return clsx(
`text-error-9 bg-error-2`,
`th-dark:text-error-3 th-dark:bg-error-10`,
`th-highcontrast:text-error-3 th-highcontrast:bg-error-10`
);
case 'info':
return clsx(
`text-blue-9 bg-blue-2`,
`th-dark:text-blue-3 th-dark:bg-blue-10`,
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
);
default:
return clsx(
`text-blue-9 bg-blue-2`,
`th-dark:text-blue-3 th-dark:bg-blue-10`,
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
);
}
}

@ -90,6 +90,7 @@ export function CodeEditor({
</div>
<CopyButton
fadeDelay={2500}
copyText={value}
color="link"
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"

@ -4,17 +4,31 @@ import clsx from 'clsx';
import { Icon } from '@@/Icon';
type Size = 'xs' | 'sm' | 'md';
export type Props = {
className: string;
className?: string;
size?: Size;
};
const sizeStyles: Record<Size, string> = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-md',
};
export function InlineLoader({
children,
className,
size = 'sm',
}: PropsWithChildren<Props>) {
return (
<div
className={clsx('text-muted flex items-center gap-2 text-sm', className)}
className={clsx(
'text-muted flex items-center gap-2',
className,
sizeStyles[size]
)}
>
<Icon icon={Loader2} className="animate-spin-slow" />
{children}

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { ChevronUp, ChevronRight, Edit } from 'lucide-react';
import { Button } from '@@/buttons';
import { FormError } from '@@/form-components/FormError';
export type Props = {
onChange: (value: string) => void;
defaultIsOpen?: boolean;
value?: string;
labelClass?: string;
inputClass?: string;
isRequired?: boolean;
minLength?: number;
isExpandable?: boolean;
};
export function Note({
onChange,
defaultIsOpen,
value,
labelClass = 'col-sm-12 mb-2',
inputClass = 'col-sm-12',
isRequired,
minLength,
isExpandable,
}: Props) {
const [isNoteOpen, setIsNoteOpen] = useState(defaultIsOpen || isRequired);
useEffect(() => {
setIsNoteOpen(defaultIsOpen || isRequired);
}, [defaultIsOpen, isRequired]);
let error = '';
if (isRequired && minLength && (!value || value.length < minLength)) {
error = `You have entered ${value ? value.length : 0} of the ${minLength} ${
minLength === 1 ? 'character' : 'characters'
} minimum required.`;
}
return (
<div className="form-group">
<div className={clsx('vertical-center', labelClass)}>
{isExpandable && (
<Button
size="small"
type="button"
color="none"
data-cy="k8sAppDetail-expandNoteButton"
onClick={() => setIsNoteOpen(!isNoteOpen)}
className="!m-0 !p-0"
>
{isNoteOpen ? <ChevronUp /> : <ChevronRight />} <Edit />
<span className={isRequired ? 'required' : ''}>Note</span>
</Button>
)}
{!isExpandable && (
<span
className={clsx(
'control-label text-left',
isRequired ? 'required' : ''
)}
>
Note
</span>
)}
</div>
{isNoteOpen && (
<div className={inputClass}>
<textarea
className="form-control resize-y"
name="application_note"
id="application_note"
value={value}
onChange={(e) => onChange(e.target.value)}
rows={5}
placeholder="Enter a note about this application..."
minLength={minLength}
/>
{error && (
<FormError className="error-inline mt-2">{error}</FormError>
)}
</div>
)}
</div>
);
}

@ -0,0 +1 @@
export { Note, type Props } from './Note';

@ -6,7 +6,7 @@ import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
export interface Tab {
name: string;
name: ReactNode;
icon: ReactNode;
widget: ReactNode;
selectedTabParam: string;
@ -17,6 +17,7 @@ interface Props {
tabs: Tab[];
}
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?type=design&node-id=148-2676&mode=design&t=JKyBWBupeC5WADk6-0
export function WidgetTabs({ currentTabIndex, tabs }: Props) {
// ensure that the selectedTab param is always valid
const invalidQueryParamValue = tabs.every(
@ -37,10 +38,13 @@ export function WidgetTabs({ currentTabIndex, tabs }: Props) {
params={{ tab: tabs[index].selectedTabParam }}
key={index}
className={clsx(
'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2',
currentTabIndex === index
? 'border-blue-8 text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6'
: 'border-transparent'
'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2 hover:no-underline',
{
'border-blue-8 text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6':
currentTabIndex === index,
'border-transparent text-gray-7 hover:text-gray-8 th-highcontrast:text-gray-6 hover:th-highcontrast:text-gray-5 th-dark:text-gray-6 hover:th-dark:text-gray-5':
currentTabIndex !== index,
}
)}
>
<Icon icon={icon} />

@ -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}
/>
</>
)}
{!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 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>
)}
</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>
)}
{!applicationIsKind<Pod>('Pod', application) && (
<td data-cy="k8sAppDetail-appType">
{appKindToDeploymentTypeMap[application.kind]}
<code className="ml-1">
{getRunningPods(application)}
</code> / <code>{getTotalPods(application)}</code>
</td>
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody>
{applicationQuery.isLoading && (
<InlineLoader>Loading application...</InlineLoader>
)}
</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'}
)}
<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 }}
>
{applicationNote ? 'Update' : 'Save'} note
</LoadingButton>
{namespace}
</Link>
{systemNamespace && <Badge type="info">system</Badge>}
</div>
</div>
</Authorized>
</>
)}
</form>
</td>
</tr>
</DetailsTable>
</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&apos;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) {

@ -0,0 +1,70 @@
import { NodeList, Node } from 'kubernetes-types/core/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';
const queryKeys = {
node: (environmentId: number, nodeName: string) => [
'environments',
environmentId,
'kubernetes',
'nodes',
nodeName,
],
nodes: (environmentId: number) => [
'environments',
environmentId,
'kubernetes',
'nodes',
],
};
async function getNode(environmentId: EnvironmentId, nodeName: string) {
const { data: node } = await axios.get<Node>(
`/endpoints/${environmentId}/kubernetes/api/v1/nodes/${nodeName}`
);
return node;
}
export function useNodeQuery(environmentId: EnvironmentId, nodeName: string) {
return useQuery(
queryKeys.node(environmentId, nodeName),
() => getNode(environmentId, nodeName),
{
...withError(
'Unable to get node details from the Kubernetes api',
'Failed to get node details'
),
}
);
}
// getNodes is used to get a list of nodes using the kubernetes API
async function getNodes(environmentId: EnvironmentId) {
const { data: nodeList } = await axios.get<NodeList>(
`/endpoints/${environmentId}/kubernetes/api/v1/nodes`
);
return nodeList.items;
}
// useNodesQuery is used to get an array of nodes using the kubernetes API
export function useNodesQuery(
environmentId: EnvironmentId,
options?: { autoRefreshRate?: number }
) {
return useQuery(
queryKeys.nodes(environmentId),
async () => getNodes(environmentId),
{
...withError(
'Failed to get nodes from the Kubernetes api',
'Failed to get nodes'
),
refetchInterval() {
return options?.autoRefreshRate ?? false;
},
}
);
}

@ -15,7 +15,7 @@ type Props = {
tableState: TableState<TableSettings>;
isLoading: boolean;
'data-cy': string;
noWidget: boolean;
noWidget?: boolean;
};
export function EventsDatatable({

@ -11,9 +11,10 @@ import { BETeaserButton } from '@@/BETeaserButton';
type Props = {
identifier: string;
data: string;
hideMessage?: boolean;
};
export function YAMLInspector({ identifier, data }: Props) {
export function YAMLInspector({ identifier, data, hideMessage }: Props) {
const [expanded, setExpanded] = useState(false);
const yaml = useMemo(() => cleanYamlUnwantedFields(data), [data]);
@ -21,7 +22,11 @@ export function YAMLInspector({ identifier, data }: Props) {
<div>
<WebEditorForm
value={yaml}
placeholder="Define or paste the content of your manifest here"
placeholder={
hideMessage
? undefined
: 'Define or paste the content of your manifest here'
}
readonly
hideTitle
id={identifier}

@ -6,7 +6,7 @@ import { debounce } from 'lodash';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useConfigurations } from '@/react/kubernetes/configs/queries';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { useServices } from '@/react/kubernetes/networks/services/queries';
import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { useAuthorizations } from '@/react/hooks/useUser';
@ -62,7 +62,7 @@ export function CreateIngressView() {
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
const { data: allServices } = useServices(environmentId, namespace);
const { data: allServices } = useNamespaceServices(environmentId, namespace);
const configResults = useConfigurations(environmentId, namespace);
const ingressesResults = useIngresses(
environmentId,

@ -6,7 +6,10 @@ import { error as notifyError } from '@/portainer/services/notifications';
import { getServices } from './service';
import { Service } from './types';
export function useServices(environmentId: EnvironmentId, namespace: string) {
export function useNamespaceServices(
environmentId: EnvironmentId,
namespace: string
) {
return useQuery(
[
'environments',

@ -7,11 +7,13 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { isFulfilled } from '@/portainer/helpers/promise-utils';
import {
Service,
NodeMetrics,
NodeMetric,
Service,
} from '@/react/kubernetes/services/types';
import { parseKubernetesAxiosError } from '../axiosError';
export const queryKeys = {
clusterServices: (environmentId: EnvironmentId) =>
['environments', environmentId, 'kubernetes', 'services'] as const,
@ -47,6 +49,31 @@ export function useServicesForCluster(
);
}
// get a list of services, based on an array of service names, for a specific namespace
export function useServicesQuery<T extends Service | string = Service>(
environmentId: EnvironmentId,
namespace: string,
serviceNames: string[],
options?: { yaml?: boolean }
) {
return useQuery(
['environments', environmentId, 'kubernetes', 'services', serviceNames],
async () => {
// promise.all is best in this case because I want to return an error if even one service request has an error
const services = await Promise.all(
serviceNames.map((serviceName) =>
getService<T>(environmentId, namespace, serviceName, options?.yaml)
)
);
return services;
},
{
...withError('Unable to retrieve services.'),
enabled: !!serviceNames?.length,
}
);
}
export function useMutationDeleteServices(environmentId: EnvironmentId) {
const queryClient = useQueryClient();
return useMutation(deleteServices, {
@ -57,7 +84,7 @@ export function useMutationDeleteServices(environmentId: EnvironmentId) {
}
// get a list of services for a specific namespace from the Portainer API
async function getServices(
export async function getServices(
environmentId: EnvironmentId,
namespace: string,
lookupApps: boolean
@ -77,19 +104,45 @@ async function getServices(
}
}
// getNamespaceServices is used to get a list of services for a specific namespace directly from the Kubernetes API
// getNamespaceServices is used to get a list of services for a specific namespace
// it calls the kubernetes api directly and not the portainer api
export async function getNamespaceServices(
environmentId: EnvironmentId,
namespace: string,
queryParams?: Record<string, string>
) {
const { data: services } = await axios.get<ServiceList>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services`,
{
params: queryParams,
}
);
return services.items;
try {
const { data: services } = await axios.get<ServiceList>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services`,
{
params: queryParams,
}
);
return services.items;
} catch (e) {
throw parseKubernetesAxiosError(e as Error, 'Unable to retrieve services');
}
}
async function getService<T extends Service | string = Service>(
environmentId: EnvironmentId,
namespace: string,
serviceName: string,
yaml?: boolean
) {
try {
const { data: service } = await axios.get<T>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services/${serviceName}`,
{
headers: {
Accept: yaml ? 'application/yaml' : 'application/json',
},
}
);
return service;
} catch (e) {
throw parseKubernetesAxiosError(e as Error, 'Unable to retrieve service');
}
}
export async function deleteServices({

Loading…
Cancel
Save