mirror of https://github.com/portainer/portainer
feat(app): migrate app parent view to react [EE-5361] (#10086)
Co-authored-by: testa113 <testa113>pull/10191/head
parent
531f88b947
commit
841ca1ebd4
|
@ -151,10 +151,10 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||||
|
|
||||||
const application = {
|
const application = {
|
||||||
name: 'kubernetes.applications.application',
|
name: 'kubernetes.applications.application',
|
||||||
url: '/:namespace/:name?resource-type',
|
url: '/:namespace/:name?resource-type&tab',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'kubernetesApplicationView',
|
component: 'applicationDetailsView',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,7 +20,6 @@ import {
|
||||||
import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable';
|
import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable';
|
||||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { PlacementsDatatable } from '@/react/kubernetes/applications/ItemView/PlacementsDatatable';
|
|
||||||
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
|
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
|
||||||
|
|
||||||
export const ngModule = angular
|
export const ngModule = angular
|
||||||
|
@ -102,6 +101,7 @@ export const ngModule = angular
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [
|
r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [
|
||||||
'identifier',
|
'identifier',
|
||||||
'data',
|
'data',
|
||||||
|
'hideMessage',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
|
@ -133,13 +133,6 @@ export const ngModule = angular
|
||||||
withUIRouter(withReactQuery(withCurrentUser(ApplicationEventsDatatable))),
|
withUIRouter(withReactQuery(withCurrentUser(ApplicationEventsDatatable))),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
)
|
|
||||||
.component(
|
|
||||||
'kubernetesApplicationPlacementsDatatable',
|
|
||||||
r2a(withUIRouter(withCurrentUser(PlacementsDatatable)), [
|
|
||||||
'dataset',
|
|
||||||
'onRefresh',
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const componentsModule = ngModule.name;
|
export const componentsModule = ngModule.name;
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { DashboardView } from '@/react/kubernetes/dashboard/DashboardView';
|
||||||
import { ServicesView } from '@/react/kubernetes/services/ServicesView';
|
import { ServicesView } from '@/react/kubernetes/services/ServicesView';
|
||||||
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
|
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
|
||||||
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
|
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
|
||||||
|
import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.kubernetes.react.views', [])
|
.module('portainer.kubernetes.react.views', [])
|
||||||
|
@ -35,6 +36,13 @@ export const viewsModule = angular
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.component(
|
||||||
|
'applicationDetailsView',
|
||||||
|
r2a(
|
||||||
|
withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsView))),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
)
|
||||||
.component(
|
.component(
|
||||||
'kubernetesDashboardView',
|
'kubernetesDashboardView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), [])
|
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'])
|
r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className'])
|
||||||
)
|
)
|
||||||
.component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin']))
|
.component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin']))
|
||||||
.component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, []))
|
.component(
|
||||||
|
'reactQueryDevTools',
|
||||||
|
r2a(withReactQuery(ReactQueryDevtoolsWrapper), [])
|
||||||
|
)
|
||||||
.component(
|
.component(
|
||||||
'dashboardItem',
|
'dashboardItem',
|
||||||
r2a(DashboardItem, [
|
r2a(DashboardItem, [
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Meta } from '@storybook/react';
|
import { Meta } from '@storybook/react';
|
||||||
|
|
||||||
import { Badge, Props } from './Badge';
|
import { Badge, BadgeType, Props } from './Badge';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
component: Badge,
|
component: Badge,
|
||||||
|
@ -17,11 +17,15 @@ export default {
|
||||||
|
|
||||||
// : JSX.IntrinsicAttributes & PropsWithChildren<Props>
|
// : JSX.IntrinsicAttributes & PropsWithChildren<Props>
|
||||||
function Template({ type = 'success' }: Props) {
|
function Template({ type = 'success' }: Props) {
|
||||||
const message = {
|
const message: Record<BadgeType, string> = {
|
||||||
success: 'success badge',
|
success: 'success badge',
|
||||||
danger: 'danger badge',
|
danger: 'danger badge',
|
||||||
warn: 'warn badge',
|
warn: 'warn badge',
|
||||||
info: 'info badge',
|
info: 'info badge',
|
||||||
|
successSecondary: 'successSecondary badge',
|
||||||
|
dangerSecondary: 'dangerSecondary badge',
|
||||||
|
warnSecondary: 'warnSecondary badge',
|
||||||
|
infoSecondary: 'infoSecondary badge',
|
||||||
};
|
};
|
||||||
return <Badge type={type}>{message[type]}</Badge>;
|
return <Badge type={type}>{message[type]}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,60 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { PropsWithChildren } from 'react';
|
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 {
|
export interface Props {
|
||||||
type?: BadgeType;
|
type?: BadgeType;
|
||||||
|
@ -10,50 +63,17 @@ export interface Props {
|
||||||
|
|
||||||
// this component is used in tables and lists in portainer. It looks like this:
|
// 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
|
// 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 =
|
const baseClasses =
|
||||||
'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
|
'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
|
||||||
const typeClasses = getClasses(type);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={clsx(baseClasses, typeClasses, className)}>
|
<span className={clsx(baseClasses, typeClasses[type], className)}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<CopyButton
|
<CopyButton
|
||||||
|
fadeDelay={2500}
|
||||||
copyText={value}
|
copyText={value}
|
||||||
color="link"
|
color="link"
|
||||||
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
|
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';
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
type Size = 'xs' | 'sm' | 'md';
|
||||||
|
|
||||||
export type Props = {
|
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({
|
export function InlineLoader({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
size = 'sm',
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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" />
|
<Icon icon={Loader2} className="animate-spin-slow" />
|
||||||
{children}
|
{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';
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
name: string;
|
name: ReactNode;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
widget: ReactNode;
|
widget: ReactNode;
|
||||||
selectedTabParam: string;
|
selectedTabParam: string;
|
||||||
|
@ -17,6 +17,7 @@ interface Props {
|
||||||
tabs: Tab[];
|
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) {
|
export function WidgetTabs({ currentTabIndex, tabs }: Props) {
|
||||||
// ensure that the selectedTab param is always valid
|
// ensure that the selectedTab param is always valid
|
||||||
const invalidQueryParamValue = tabs.every(
|
const invalidQueryParamValue = tabs.every(
|
||||||
|
@ -37,10 +38,13 @@ export function WidgetTabs({ currentTabIndex, tabs }: Props) {
|
||||||
params={{ tab: tabs[index].selectedTabParam }}
|
params={{ tab: tabs[index].selectedTabParam }}
|
||||||
key={index}
|
key={index}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2',
|
'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2 hover:no-underline',
|
||||||
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-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'
|
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} />
|
<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"
|
emptyContentLabel="No containers found"
|
||||||
title="Application containers"
|
title="Application containers"
|
||||||
titleIcon={Server}
|
titleIcon={Server}
|
||||||
getRowId={(row) => row.name}
|
getRowId={(row) => row.podName} // use pod name because it's unique (name is not unique)
|
||||||
disableSelect
|
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 { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
|
||||||
import { Application } from '../../types';
|
import { Application } from '../../types';
|
||||||
import { useApplicationHorizontalPodAutoscalers } from '../../application.queries';
|
import { useApplicationHorizontalPodAutoscaler } from '../../application.queries';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
environmentId: EnvironmentId;
|
environmentId: EnvironmentId;
|
||||||
|
@ -22,7 +22,7 @@ export function ApplicationAutoScalingTable({
|
||||||
appName,
|
appName,
|
||||||
app,
|
app,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { data: appAutoScalar } = useApplicationHorizontalPodAutoscalers(
|
const { data: appAutoScalar } = useApplicationHorizontalPodAutoscaler(
|
||||||
environmentId,
|
environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
appName,
|
appName,
|
||||||
|
|
|
@ -40,8 +40,12 @@ export function ApplicationDetailsWidget() {
|
||||||
} = stateAndParams;
|
} = stateAndParams;
|
||||||
|
|
||||||
// get app info
|
// get app info
|
||||||
const appQuery = useApplication(environmentId, namespace, name, resourceType);
|
const { data: app } = useApplication(
|
||||||
const app = appQuery.data;
|
environmentId,
|
||||||
|
namespace,
|
||||||
|
name,
|
||||||
|
resourceType
|
||||||
|
);
|
||||||
const externalApp = app && isExternalApplication(app);
|
const externalApp = app && isExternalApplication(app);
|
||||||
const appStackId = Number(app?.metadata?.labels?.[appStackIdLabel]);
|
const appStackId = Number(app?.metadata?.labels?.[appStackIdLabel]);
|
||||||
const appStackFileQuery = useStackFile(appStackId);
|
const appStackFileQuery = useStackFile(appStackId);
|
||||||
|
@ -53,6 +57,8 @@ export function ApplicationDetailsWidget() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
<Widget>
|
<Widget>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
{!isSystemNamespace(namespace) && (
|
{!isSystemNamespace(namespace) && (
|
||||||
|
@ -138,5 +144,7 @@ export function ApplicationDetailsWidget() {
|
||||||
/>
|
/>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</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 moment from 'moment';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Pod } from 'kubernetes-types/core/v1';
|
import { Pod } from 'kubernetes-types/core/v1';
|
||||||
|
@ -10,7 +10,11 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||||
import { DetailsTable } from '@@/DetailsTable';
|
import { DetailsTable } from '@@/DetailsTable';
|
||||||
import { Badge } from '@@/Badge';
|
import { Badge } from '@@/Badge';
|
||||||
import { Link } from '@@/Link';
|
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 { isSystemNamespace } from '../../namespaces/utils';
|
||||||
import {
|
import {
|
||||||
|
@ -44,13 +48,12 @@ export function ApplicationSummaryWidget() {
|
||||||
endpointId: environmentId,
|
endpointId: environmentId,
|
||||||
},
|
},
|
||||||
} = stateAndParams;
|
} = stateAndParams;
|
||||||
const applicationQuery = useApplication(
|
const { data: application, ...applicationQuery } = useApplication(
|
||||||
environmentId,
|
environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
name,
|
name,
|
||||||
resourceType
|
resourceType
|
||||||
);
|
);
|
||||||
const application = applicationQuery.data;
|
|
||||||
const systemNamespace = isSystemNamespace(namespace);
|
const systemNamespace = isSystemNamespace(namespace);
|
||||||
const externalApplication = application && isExternalApplication(application);
|
const externalApplication = application && isExternalApplication(application);
|
||||||
const applicationRequests = application && getResourceRequests(application);
|
const applicationRequests = application && getResourceRequests(application);
|
||||||
|
@ -59,7 +62,6 @@ export function ApplicationSummaryWidget() {
|
||||||
const applicationNote =
|
const applicationNote =
|
||||||
application?.metadata?.annotations?.[appNoteAnnotation];
|
application?.metadata?.annotations?.[appNoteAnnotation];
|
||||||
|
|
||||||
const [isNoteOpen, setIsNoteOpen] = useState(true);
|
|
||||||
const [applicationNoteFormValues, setApplicationNoteFormValues] =
|
const [applicationNoteFormValues, setApplicationNoteFormValues] =
|
||||||
useState('');
|
useState('');
|
||||||
|
|
||||||
|
@ -67,6 +69,9 @@ export function ApplicationSummaryWidget() {
|
||||||
setApplicationNoteFormValues(applicationNote || '');
|
setApplicationNoteFormValues(applicationNote || '');
|
||||||
}, [applicationNote]);
|
}, [applicationNote]);
|
||||||
|
|
||||||
|
const failedCreateCondition = application?.status?.conditions?.find(
|
||||||
|
(condition) => condition.reason === 'FailedCreate'
|
||||||
|
);
|
||||||
const patchApplicationMutation = usePatchApplicationMutation(
|
const patchApplicationMutation = usePatchApplicationMutation(
|
||||||
environmentId,
|
environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
|
@ -74,7 +79,29 @@ export function ApplicationSummaryWidget() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5">
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<WidgetBody>
|
||||||
|
{applicationQuery.isLoading && (
|
||||||
|
<InlineLoader>Loading application...</InlineLoader>
|
||||||
|
)}
|
||||||
|
{application && (
|
||||||
|
<>
|
||||||
|
{failedCreateCondition && (
|
||||||
|
<div
|
||||||
|
className="vertical-center alert alert-danger mb-2"
|
||||||
|
data-cy="k8sAppDetail-failedCreateMessage"
|
||||||
|
>
|
||||||
|
<Icon icon={Info} className="mr-1" mode="danger" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">
|
||||||
|
Failed to create application
|
||||||
|
</div>
|
||||||
|
{failedCreateCondition.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DetailsTable>
|
<DetailsTable>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Name</td>
|
<td>Name</td>
|
||||||
|
@ -93,7 +120,8 @@ export function ApplicationSummaryWidget() {
|
||||||
<tr>
|
<tr>
|
||||||
<td>Stack</td>
|
<td>Stack</td>
|
||||||
<td data-cy="k8sAppDetail-stackName">
|
<td data-cy="k8sAppDetail-stackName">
|
||||||
{application?.metadata?.labels?.[appStackNameLabel] || '-'}
|
{application?.metadata?.labels?.[appStackNameLabel] ||
|
||||||
|
'-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -115,7 +143,9 @@ export function ApplicationSummaryWidget() {
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Application type</td>
|
<td>Application type</td>
|
||||||
<td data-cy="k8sAppDetail-appType">{application?.kind || '-'}</td>
|
<td data-cy="k8sAppDetail-appType">
|
||||||
|
{application?.kind || '-'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{application?.kind && (
|
{application?.kind && (
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -130,12 +160,14 @@ export function ApplicationSummaryWidget() {
|
||||||
{appKindToDeploymentTypeMap[application.kind]}
|
{appKindToDeploymentTypeMap[application.kind]}
|
||||||
<code className="ml-1">
|
<code className="ml-1">
|
||||||
{getRunningPods(application)}
|
{getRunningPods(application)}
|
||||||
</code> / <code>{getTotalPods(application)}</code>
|
</code>{' '}
|
||||||
|
/ <code>{getTotalPods(application)}</code>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{(!!applicationRequests?.cpu || !!applicationRequests?.memoryBytes) && (
|
{(!!applicationRequests?.cpu ||
|
||||||
|
!!applicationRequests?.memoryBytes) && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
Resource reservations
|
Resource reservations
|
||||||
|
@ -152,7 +184,9 @@ export function ApplicationSummaryWidget() {
|
||||||
{!!applicationRequests?.memoryBytes && (
|
{!!applicationRequests?.memoryBytes && (
|
||||||
<div data-cy="k8sAppDetail-memoryReservation">
|
<div data-cy="k8sAppDetail-memoryReservation">
|
||||||
Memory{' '}
|
Memory{' '}
|
||||||
{bytesToReadableFormat(applicationRequests.memoryBytes)}
|
{bytesToReadableFormat(
|
||||||
|
applicationRequests.memoryBytes
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
@ -176,9 +210,9 @@ export function ApplicationSummaryWidget() {
|
||||||
data-cy="k8sAppDetail-creationDate"
|
data-cy="k8sAppDetail-creationDate"
|
||||||
>
|
>
|
||||||
<Clock />
|
<Clock />
|
||||||
{moment(application?.metadata?.creationTimestamp).format(
|
{moment(
|
||||||
'YYYY-MM-DD HH:mm:ss'
|
application?.metadata?.creationTimestamp
|
||||||
)}
|
).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
</span>
|
</span>
|
||||||
{(!externalApplication || systemNamespace) && (
|
{(!externalApplication || systemNamespace) && (
|
||||||
<span
|
<span
|
||||||
|
@ -195,40 +229,12 @@ export function ApplicationSummaryWidget() {
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={2}>
|
<td colSpan={2}>
|
||||||
<form className="form-horizontal">
|
<form className="form-horizontal">
|
||||||
<div className="form-group">
|
<Note
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{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}
|
value={applicationNoteFormValues}
|
||||||
onChange={(e) =>
|
onChange={setApplicationNoteFormValues}
|
||||||
setApplicationNoteFormValues(e.target.value)
|
defaultIsOpen
|
||||||
}
|
isExpandable
|
||||||
rows={5}
|
|
||||||
placeholder="Enter a note about this application..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Authorized authorizations="K8sApplicationDetailsW">
|
<Authorized authorizations="K8sApplicationDetailsW">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
|
@ -246,19 +252,24 @@ export function ApplicationSummaryWidget() {
|
||||||
}
|
}
|
||||||
data-cy="k8sAppDetail-saveNoteButton"
|
data-cy="k8sAppDetail-saveNoteButton"
|
||||||
isLoading={patchApplicationMutation.isLoading}
|
isLoading={patchApplicationMutation.isLoading}
|
||||||
loadingText={applicationNote ? 'Updating' : 'Saving'}
|
loadingText={
|
||||||
|
applicationNote ? 'Updating' : 'Saving'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{applicationNote ? 'Update' : 'Save'} note
|
{applicationNote ? 'Update' : 'Save'} note
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</DetailsTable>
|
</DetailsTable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -2,53 +2,60 @@ import { Minimize2 } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BasicTableSettings,
|
BasicTableSettings,
|
||||||
createPersistedStore,
|
|
||||||
refreshableSettings,
|
|
||||||
RefreshableTableSettings,
|
RefreshableTableSettings,
|
||||||
} from '@@/datatables/types';
|
} from '@@/datatables/types';
|
||||||
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
|
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
|
||||||
import { useRepeater } from '@@/datatables/useRepeater';
|
|
||||||
import { TableSettingsMenu } from '@@/datatables';
|
import { TableSettingsMenu } from '@@/datatables';
|
||||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
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 { SubRow } from './PlacementsDatatableSubRow';
|
||||||
import { columns } from './columns';
|
import { columns } from './columns';
|
||||||
|
|
||||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
||||||
|
|
||||||
function createStore(storageKey: string) {
|
type Props = {
|
||||||
return createPersistedStore<TableSettings>(storageKey, 'node', (set) => ({
|
isLoading: boolean;
|
||||||
...refreshableSettings(set),
|
dataset: NodePlacementRowData[];
|
||||||
}));
|
hasPlacementWarning: boolean;
|
||||||
}
|
tableState: TableSettings & {
|
||||||
|
setSearch: (value: string) => void;
|
||||||
const storageKey = 'kubernetes.application.placements';
|
search: string;
|
||||||
const settingsStore = createStore(storageKey);
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function PlacementsDatatable({
|
export function PlacementsDatatable({
|
||||||
|
isLoading,
|
||||||
dataset,
|
dataset,
|
||||||
onRefresh,
|
hasPlacementWarning,
|
||||||
}: {
|
tableState,
|
||||||
dataset: Node[];
|
}: Props) {
|
||||||
onRefresh: () => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const tableState = useTableState(settingsStore, storageKey);
|
|
||||||
|
|
||||||
useRepeater(tableState.autoRefreshRate, onRefresh);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandableDatatable
|
<ExpandableDatatable
|
||||||
getRowCanExpand={(row) => !row.original.AcceptsApplication}
|
isLoading={isLoading}
|
||||||
|
getRowCanExpand={(row) => !row.original.acceptsApplication}
|
||||||
title="Placement constraints/preferences"
|
title="Placement constraints/preferences"
|
||||||
titleIcon={Minimize2}
|
titleIcon={Minimize2}
|
||||||
dataset={dataset}
|
dataset={dataset}
|
||||||
settingsManager={tableState}
|
settingsManager={tableState}
|
||||||
|
getRowId={(row) => row.name}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
disableSelect
|
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={() => (
|
renderTableSettings={() => (
|
||||||
<TableSettingsMenu>
|
<TableSettingsMenu>
|
||||||
<TableSettingsMenuAutoRefresh
|
<TableSettingsMenuAutoRefresh
|
|
@ -1,13 +1,14 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
import { Taint } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
import { nodeAffinityValues } from '@/kubernetes/filters/application';
|
import { nodeAffinityValues } from '@/kubernetes/filters/application';
|
||||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
import { Affinity, Label, Node, Taint } from '../types';
|
import { Affinity, Label, NodePlacementRowData } from '../types';
|
||||||
|
|
||||||
interface SubRowProps {
|
interface SubRowProps {
|
||||||
node: Node;
|
node: NodePlacementRowData;
|
||||||
cellCount: number;
|
cellCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,11 +21,11 @@ export function SubRow({ node, cellCount }: SubRowProps) {
|
||||||
|
|
||||||
if (!authorized) {
|
if (!authorized) {
|
||||||
<>
|
<>
|
||||||
{isDefined(node.UnmetTaints) && (
|
{isDefined(node.unmetTaints) && (
|
||||||
<tr
|
<tr
|
||||||
className={clsx({
|
className={clsx({
|
||||||
'datatable-highlighted': node.Highlighted,
|
'datatable-highlighted': node.highlighted,
|
||||||
'datatable-unhighlighted': !node.Highlighted,
|
'datatable-unhighlighted': !node.highlighted,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<td colSpan={cellCount}>
|
<td colSpan={cellCount}>
|
||||||
|
@ -33,12 +34,12 @@ export function SubRow({ node, cellCount }: SubRowProps) {
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(isDefined(node.UnmatchedNodeSelectorLabels) ||
|
{(isDefined(node.unmatchedNodeSelectorLabels) ||
|
||||||
isDefined(node.UnmatchedNodeAffinities)) && (
|
isDefined(node.unmatchedNodeAffinities)) && (
|
||||||
<tr
|
<tr
|
||||||
className={clsx({
|
className={clsx({
|
||||||
'datatable-highlighted': node.Highlighted,
|
'datatable-highlighted': node.highlighted,
|
||||||
'datatable-unhighlighted': !node.Highlighted,
|
'datatable-unhighlighted': !node.highlighted,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<td colSpan={cellCount}>
|
<td colSpan={cellCount}>
|
||||||
|
@ -51,25 +52,25 @@ export function SubRow({ node, cellCount }: SubRowProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDefined(node.UnmetTaints) && (
|
{isDefined(node.unmetTaints) && (
|
||||||
<UnmetTaintsInfo
|
<UnmetTaintsInfo
|
||||||
taints={node.UnmetTaints}
|
taints={node.unmetTaints}
|
||||||
cellCount={cellCount}
|
cellCount={cellCount}
|
||||||
isHighlighted={node.Highlighted}
|
isHighlighted={node.highlighted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isDefined(node.UnmatchedNodeSelectorLabels) && (
|
{isDefined(node.unmatchedNodeSelectorLabels) && (
|
||||||
<UnmatchedLabelsInfo
|
<UnmatchedLabelsInfo
|
||||||
labels={node.UnmatchedNodeSelectorLabels}
|
labels={node.unmatchedNodeSelectorLabels}
|
||||||
cellCount={cellCount}
|
cellCount={cellCount}
|
||||||
isHighlighted={node.Highlighted}
|
isHighlighted={node.highlighted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isDefined(node.UnmatchedNodeAffinities) && (
|
{isDefined(node.unmatchedNodeAffinities) && (
|
||||||
<UnmatchedAffinitiesInfo
|
<UnmatchedAffinitiesInfo
|
||||||
affinities={node.UnmatchedNodeAffinities}
|
affinities={node.unmatchedNodeAffinities}
|
||||||
cellCount={cellCount}
|
cellCount={cellCount}
|
||||||
isHighlighted={node.Highlighted}
|
isHighlighted={node.highlighted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -97,13 +98,13 @@ function UnmetTaintsInfo({
|
||||||
'datatable-highlighted': isHighlighted,
|
'datatable-highlighted': isHighlighted,
|
||||||
'datatable-unhighlighted': !isHighlighted,
|
'datatable-unhighlighted': !isHighlighted,
|
||||||
})}
|
})}
|
||||||
key={taint.Key}
|
key={taint.key}
|
||||||
>
|
>
|
||||||
<td colSpan={cellCount}>
|
<td colSpan={cellCount}>
|
||||||
This application is missing a toleration for the taint
|
This application is missing a toleration for the taint
|
||||||
<code className="space-left">
|
<code className="space-left">
|
||||||
{taint.Key}
|
{taint.key}
|
||||||
{taint.Value ? `=${taint.Value}` : ''}:{taint.Effect}
|
{taint.value ? `=${taint.value}` : ''}:{taint.effect}
|
||||||
</code>
|
</code>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 { buildExpandColumn } from '@@/datatables/expand-column';
|
||||||
|
|
||||||
import { Node } from '../../types';
|
import { NodePlacementRowData } from '../../types';
|
||||||
|
|
||||||
import { columnHelper } from './helper';
|
import { columnHelper } from './helper';
|
||||||
import { status } from './status';
|
import { status } from './status';
|
||||||
|
|
||||||
export const columns = [
|
export const columns = [
|
||||||
buildExpandColumn<Node>(),
|
buildExpandColumn<NodePlacementRowData>(),
|
||||||
status,
|
status,
|
||||||
columnHelper.accessor('Name', {
|
columnHelper.accessor('name', {
|
||||||
header: 'Node',
|
header: 'Node',
|
||||||
id: 'node',
|
id: 'node',
|
||||||
}),
|
}),
|
|
@ -4,7 +4,7 @@ import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
import { columnHelper } from './helper';
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
export const status = columnHelper.accessor('AcceptsApplication', {
|
export const status = columnHelper.accessor('acceptsApplication', {
|
||||||
header: '',
|
header: '',
|
||||||
id: 'status',
|
id: 'status',
|
||||||
enableSorting: false,
|
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 {
|
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '@/kubernetes/pod/models';
|
||||||
Key: string;
|
|
||||||
Value?: string;
|
|
||||||
Effect: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Label {
|
export interface Label {
|
||||||
key: string;
|
key: string;
|
||||||
|
@ -19,11 +15,11 @@ interface AffinityTerm {
|
||||||
|
|
||||||
export type Affinity = Array<AffinityTerm>;
|
export type Affinity = Array<AffinityTerm>;
|
||||||
|
|
||||||
export type Node = {
|
export type NodePlacementRowData = {
|
||||||
Name: string;
|
name: string;
|
||||||
AcceptsApplication: boolean;
|
acceptsApplication: boolean;
|
||||||
UnmetTaints?: Array<Taint>;
|
unmetTaints?: Array<Taint>;
|
||||||
UnmatchedNodeSelectorLabels?: Array<Label>;
|
unmatchedNodeSelectorLabels?: Array<Label>;
|
||||||
Highlighted: boolean;
|
highlighted: boolean;
|
||||||
UnmatchedNodeAffinities?: Array<Affinity>;
|
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 { Pod } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
import { queryClient, withError } from '@/react-tools/react-query';
|
import { queryClient, withError } from '@/react-tools/react-query';
|
||||||
|
@ -27,7 +27,8 @@ const queryKeys = {
|
||||||
application: (
|
application: (
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
name: string
|
name: string,
|
||||||
|
yaml?: boolean
|
||||||
) => [
|
) => [
|
||||||
'environments',
|
'environments',
|
||||||
environmentId,
|
environmentId,
|
||||||
|
@ -35,6 +36,7 @@ const queryKeys = {
|
||||||
'applications',
|
'applications',
|
||||||
namespace,
|
namespace,
|
||||||
name,
|
name,
|
||||||
|
yaml,
|
||||||
],
|
],
|
||||||
applicationRevisions: (
|
applicationRevisions: (
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
|
@ -120,18 +122,23 @@ export function useApplicationsForCluster(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// useQuery to get an application by environmentId, namespace and name
|
// when yaml is set to true, the expected return type is a string
|
||||||
export function useApplication(
|
export function useApplication<T extends Application | string = Application>(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
name: string,
|
name: string,
|
||||||
appKind?: AppKind
|
appKind?: AppKind,
|
||||||
) {
|
options?: { autoRefreshRate?: number; yaml?: boolean }
|
||||||
|
): UseQueryResult<T> {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
queryKeys.application(environmentId, namespace, name),
|
queryKeys.application(environmentId, namespace, name, options?.yaml),
|
||||||
() => getApplication(environmentId, namespace, name, appKind),
|
() =>
|
||||||
|
getApplication<T>(environmentId, namespace, name, appKind, options?.yaml),
|
||||||
{
|
{
|
||||||
...withError('Unable to retrieve application'),
|
...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
|
// useApplicationHorizontalPodAutoscalers returns a query for horizontal pod autoscalers that are related to the application
|
||||||
export function useApplicationHorizontalPodAutoscalers(
|
export function useApplicationHorizontalPodAutoscaler(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
appName: string,
|
appName: string,
|
||||||
|
@ -231,7 +238,7 @@ export function useApplicationHorizontalPodAutoscalers(
|
||||||
|
|
||||||
const horizontalPodAutoscalers =
|
const horizontalPodAutoscalers =
|
||||||
await getNamespaceHorizontalPodAutoscalers(environmentId, namespace);
|
await getNamespaceHorizontalPodAutoscalers(environmentId, namespace);
|
||||||
const filteredHorizontalPodAutoscalers =
|
const matchingHorizontalPodAutoscaler =
|
||||||
horizontalPodAutoscalers.find((horizontalPodAutoscaler) => {
|
horizontalPodAutoscalers.find((horizontalPodAutoscaler) => {
|
||||||
const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef;
|
const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef;
|
||||||
if (scaleTargetRef) {
|
if (scaleTargetRef) {
|
||||||
|
@ -245,11 +252,11 @@ export function useApplicationHorizontalPodAutoscalers(
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}) || null;
|
}) || null;
|
||||||
return filteredHorizontalPodAutoscalers;
|
return matchingHorizontalPodAutoscaler;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...withError(
|
...withError(
|
||||||
`Unable to get horizontal pod autoscalers${
|
`Unable to get horizontal pod autoscaler${
|
||||||
app ? ` for ${app.metadata?.name}` : ''
|
app ? ` for ${app.metadata?.name}` : ''
|
||||||
}`
|
}`
|
||||||
),
|
),
|
||||||
|
@ -263,7 +270,8 @@ export function useApplicationPods(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
appName: string,
|
appName: string,
|
||||||
app?: Application
|
app?: Application,
|
||||||
|
options?: { autoRefreshRate?: number }
|
||||||
) {
|
) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
queryKeys.applicationPods(environmentId, namespace, appName),
|
queryKeys.applicationPods(environmentId, namespace, appName),
|
||||||
|
@ -287,6 +295,9 @@ export function useApplicationPods(
|
||||||
{
|
{
|
||||||
...withError(`Unable to get pods for ${appName}`),
|
...withError(`Unable to get pods for ${appName}`),
|
||||||
enabled: !!app,
|
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
|
// 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,
|
environmentId: EnvironmentId,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
name: string,
|
name: string,
|
||||||
appKind?: AppKind
|
appKind?: AppKind,
|
||||||
|
yaml?: boolean
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// if resourceType is known, get the application by type and name
|
// if resourceType is known, get the application by type and name
|
||||||
|
@ -96,14 +99,15 @@ export async function getApplication(
|
||||||
case 'Deployment':
|
case 'Deployment':
|
||||||
case 'DaemonSet':
|
case 'DaemonSet':
|
||||||
case 'StatefulSet':
|
case 'StatefulSet':
|
||||||
return await getApplicationByKind(
|
return await getApplicationByKind<T>(
|
||||||
environmentId,
|
environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
appKind,
|
appKind,
|
||||||
name
|
name,
|
||||||
|
yaml
|
||||||
);
|
);
|
||||||
case 'Pod':
|
case 'Pod':
|
||||||
return await getPod(environmentId, namespace, name);
|
return await getPod(environmentId, namespace, name, yaml);
|
||||||
default:
|
default:
|
||||||
throw new Error('Unknown resource type');
|
throw new Error('Unknown resource type');
|
||||||
}
|
}
|
||||||
|
@ -115,21 +119,24 @@ export async function getApplication(
|
||||||
environmentId,
|
environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
'Deployment',
|
'Deployment',
|
||||||
name
|
name,
|
||||||
|
yaml
|
||||||
),
|
),
|
||||||
getApplicationByKind<DaemonSet>(
|
getApplicationByKind<DaemonSet>(
|
||||||
environmentId,
|
environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
'DaemonSet',
|
'DaemonSet',
|
||||||
name
|
name,
|
||||||
|
yaml
|
||||||
),
|
),
|
||||||
getApplicationByKind<StatefulSet>(
|
getApplicationByKind<StatefulSet>(
|
||||||
environmentId,
|
environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
'StatefulSet',
|
'StatefulSet',
|
||||||
name
|
name,
|
||||||
|
yaml
|
||||||
),
|
),
|
||||||
getPod(environmentId, namespace, name),
|
getPod(environmentId, namespace, name, yaml),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isFulfilled(deployment)) {
|
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,
|
environmentId: EnvironmentId,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
|
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
|
||||||
name: string
|
name: string,
|
||||||
|
yaml?: boolean
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<T>(
|
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;
|
return data;
|
||||||
} catch (e) {
|
} 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 axios from '@/portainer/services/axios';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
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(
|
export async function getNamespaceHorizontalPodAutoscalers(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace: string
|
namespace: string
|
||||||
) {
|
) {
|
||||||
const { data: autoScalarList } = await axios.get<HorizontalPodAutoscalerList>(
|
try {
|
||||||
|
const { data: autoScalarList } =
|
||||||
|
await axios.get<HorizontalPodAutoscalerList>(
|
||||||
`/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers`
|
`/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers`
|
||||||
);
|
);
|
||||||
return autoScalarList.items;
|
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,
|
environmentId: EnvironmentId,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
name: string
|
name: string,
|
||||||
|
yaml?: boolean
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<Pod>(
|
const { data } = await axios.get<T>(
|
||||||
buildUrl(environmentId, namespace, name)
|
buildUrl(environmentId, namespace, name),
|
||||||
|
{
|
||||||
|
headers: { Accept: yaml ? 'application/yaml' : 'application/json' },
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} 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>;
|
tableState: TableState<TableSettings>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
'data-cy': string;
|
'data-cy': string;
|
||||||
noWidget: boolean;
|
noWidget?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EventsDatatable({
|
export function EventsDatatable({
|
||||||
|
|
|
@ -11,9 +11,10 @@ import { BETeaserButton } from '@@/BETeaserButton';
|
||||||
type Props = {
|
type Props = {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
data: string;
|
data: string;
|
||||||
|
hideMessage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function YAMLInspector({ identifier, data }: Props) {
|
export function YAMLInspector({ identifier, data, hideMessage }: Props) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const yaml = useMemo(() => cleanYamlUnwantedFields(data), [data]);
|
const yaml = useMemo(() => cleanYamlUnwantedFields(data), [data]);
|
||||||
|
|
||||||
|
@ -21,7 +22,11 @@ export function YAMLInspector({ identifier, data }: Props) {
|
||||||
<div>
|
<div>
|
||||||
<WebEditorForm
|
<WebEditorForm
|
||||||
value={yaml}
|
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
|
readonly
|
||||||
hideTitle
|
hideTitle
|
||||||
id={identifier}
|
id={identifier}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { debounce } from 'lodash';
|
||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
import { useConfigurations } from '@/react/kubernetes/configs/queries';
|
import { useConfigurations } from '@/react/kubernetes/configs/queries';
|
||||||
import { useNamespaces } from '@/react/kubernetes/namespaces/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 { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
@ -62,7 +62,7 @@ export function CreateIngressView() {
|
||||||
|
|
||||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||||
|
|
||||||
const { data: allServices } = useServices(environmentId, namespace);
|
const { data: allServices } = useNamespaceServices(environmentId, namespace);
|
||||||
const configResults = useConfigurations(environmentId, namespace);
|
const configResults = useConfigurations(environmentId, namespace);
|
||||||
const ingressesResults = useIngresses(
|
const ingressesResults = useIngresses(
|
||||||
environmentId,
|
environmentId,
|
||||||
|
|
|
@ -6,7 +6,10 @@ import { error as notifyError } from '@/portainer/services/notifications';
|
||||||
import { getServices } from './service';
|
import { getServices } from './service';
|
||||||
import { Service } from './types';
|
import { Service } from './types';
|
||||||
|
|
||||||
export function useServices(environmentId: EnvironmentId, namespace: string) {
|
export function useNamespaceServices(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
namespace: string
|
||||||
|
) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[
|
[
|
||||||
'environments',
|
'environments',
|
||||||
|
|
|
@ -7,11 +7,13 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||||
import {
|
import {
|
||||||
|
Service,
|
||||||
NodeMetrics,
|
NodeMetrics,
|
||||||
NodeMetric,
|
NodeMetric,
|
||||||
Service,
|
|
||||||
} from '@/react/kubernetes/services/types';
|
} from '@/react/kubernetes/services/types';
|
||||||
|
|
||||||
|
import { parseKubernetesAxiosError } from '../axiosError';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
clusterServices: (environmentId: EnvironmentId) =>
|
clusterServices: (environmentId: EnvironmentId) =>
|
||||||
['environments', environmentId, 'kubernetes', 'services'] as const,
|
['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) {
|
export function useMutationDeleteServices(environmentId: EnvironmentId) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation(deleteServices, {
|
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
|
// get a list of services for a specific namespace from the Portainer API
|
||||||
async function getServices(
|
export async function getServices(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
lookupApps: boolean
|
lookupApps: boolean
|
||||||
|
@ -77,12 +104,14 @@ 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(
|
export async function getNamespaceServices(
|
||||||
environmentId: EnvironmentId,
|
environmentId: EnvironmentId,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
queryParams?: Record<string, string>
|
queryParams?: Record<string, string>
|
||||||
) {
|
) {
|
||||||
|
try {
|
||||||
const { data: services } = await axios.get<ServiceList>(
|
const { data: services } = await axios.get<ServiceList>(
|
||||||
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services`,
|
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services`,
|
||||||
{
|
{
|
||||||
|
@ -90,6 +119,30 @@ export async function getNamespaceServices(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return services.items;
|
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({
|
export async function deleteServices({
|
||||||
|
|
Loading…
Reference in New Issue