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 = {
 | 
			
		||||
      name: 'kubernetes.applications.application',
 | 
			
		||||
      url: '/:namespace/:name?resource-type',
 | 
			
		||||
      url: '/:namespace/:name?resource-type&tab',
 | 
			
		||||
      views: {
 | 
			
		||||
        'content@': {
 | 
			
		||||
          component: 'kubernetesApplicationView',
 | 
			
		||||
          component: 'applicationDetailsView',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,7 +20,6 @@ import {
 | 
			
		|||
import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable';
 | 
			
		||||
import { withFormValidation } from '@/react-tools/withFormValidation';
 | 
			
		||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
 | 
			
		||||
import { PlacementsDatatable } from '@/react/kubernetes/applications/ItemView/PlacementsDatatable';
 | 
			
		||||
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
 | 
			
		||||
 | 
			
		||||
export const ngModule = angular
 | 
			
		||||
| 
						 | 
				
			
			@ -102,6 +101,7 @@ export const ngModule = angular
 | 
			
		|||
    r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [
 | 
			
		||||
      'identifier',
 | 
			
		||||
      'data',
 | 
			
		||||
      'hideMessage',
 | 
			
		||||
    ])
 | 
			
		||||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
| 
						 | 
				
			
			@ -133,13 +133,6 @@ export const ngModule = angular
 | 
			
		|||
      withUIRouter(withReactQuery(withCurrentUser(ApplicationEventsDatatable))),
 | 
			
		||||
      []
 | 
			
		||||
    )
 | 
			
		||||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
    'kubernetesApplicationPlacementsDatatable',
 | 
			
		||||
    r2a(withUIRouter(withCurrentUser(PlacementsDatatable)), [
 | 
			
		||||
      'dataset',
 | 
			
		||||
      'onRefresh',
 | 
			
		||||
    ])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
export const componentsModule = ngModule.name;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,7 @@ import { DashboardView } from '@/react/kubernetes/dashboard/DashboardView';
 | 
			
		|||
import { ServicesView } from '@/react/kubernetes/services/ServicesView';
 | 
			
		||||
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
 | 
			
		||||
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
 | 
			
		||||
import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView';
 | 
			
		||||
 | 
			
		||||
export const viewsModule = angular
 | 
			
		||||
  .module('portainer.kubernetes.react.views', [])
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +36,13 @@ export const viewsModule = angular
 | 
			
		|||
      []
 | 
			
		||||
    )
 | 
			
		||||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
    'applicationDetailsView',
 | 
			
		||||
    r2a(
 | 
			
		||||
      withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsView))),
 | 
			
		||||
      []
 | 
			
		||||
    )
 | 
			
		||||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
    'kubernetesDashboardView',
 | 
			
		||||
    r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), [])
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,83 +0,0 @@
 | 
			
		|||
<page-header
 | 
			
		||||
  ng-if="ctrl.state.viewReady"
 | 
			
		||||
  title="'Application details'"
 | 
			
		||||
  breadcrumbs="[
 | 
			
		||||
    { label:'Namespaces', link:'kubernetes.resourcePools' },
 | 
			
		||||
    {
 | 
			
		||||
      label:ctrl.application.ResourcePool,
 | 
			
		||||
      link: 'kubernetes.resourcePools.resourcePool',
 | 
			
		||||
      linkParams:{ id: ctrl.application.ResourcePool }
 | 
			
		||||
    },
 | 
			
		||||
    { label:'Applications', link:'kubernetes.applications' },
 | 
			
		||||
    ctrl.application.Name
 | 
			
		||||
     ]"
 | 
			
		||||
  reload="true"
 | 
			
		||||
>
 | 
			
		||||
</page-header>
 | 
			
		||||
 | 
			
		||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
 | 
			
		||||
 | 
			
		||||
<div ng-if="ctrl.state.viewReady">
 | 
			
		||||
  <div class="row kubernetes-application">
 | 
			
		||||
    <div class="col-sm-12">
 | 
			
		||||
      <rd-widget>
 | 
			
		||||
        <rd-widget-body classes="no-padding">
 | 
			
		||||
          <uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
 | 
			
		||||
            <uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
 | 
			
		||||
              <uib-tab-heading> <pr-icon icon="'svg-laptopcode'" class-name="'mr-1'"></pr-icon> Application </uib-tab-heading>
 | 
			
		||||
              <application-summary-widget></application-summary-widget>
 | 
			
		||||
            </uib-tab>
 | 
			
		||||
 | 
			
		||||
            <uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
 | 
			
		||||
              <uib-tab-heading>
 | 
			
		||||
                <pr-icon icon="'minimize-2'"></pr-icon> Placement
 | 
			
		||||
                <div ng-if="ctrl.state.placementWarning" class="vertical-center">
 | 
			
		||||
                  <pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
 | 
			
		||||
                  warning
 | 
			
		||||
                </div>
 | 
			
		||||
              </uib-tab-heading>
 | 
			
		||||
              <div class="small text-muted vertical-center" style="padding: 20px">
 | 
			
		||||
                <pr-icon icon="'info'" mode="'primary'"></pr-icon>
 | 
			
		||||
                The placement component helps you understand whether or not this application can be deployed on a specific node.
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <kubernetes-application-placements-datatable
 | 
			
		||||
                ng-if="ctrl.placements"
 | 
			
		||||
                dataset="ctrl.placements"
 | 
			
		||||
                on-refresh="(ctrl.getApplication)"
 | 
			
		||||
              ></kubernetes-application-placements-datatable>
 | 
			
		||||
            </uib-tab>
 | 
			
		||||
 | 
			
		||||
            <uib-tab index="2" classes="btn-sm" select="ctrl.selectTab(2)">
 | 
			
		||||
              <uib-tab-heading>
 | 
			
		||||
                <pr-icon icon="'history'"></pr-icon> Events
 | 
			
		||||
                <div ng-if="ctrl.hasEventWarnings()" class="vertical-center">
 | 
			
		||||
                  <pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
 | 
			
		||||
                  {{ ctrl.state.eventWarningCount }} warning(s)
 | 
			
		||||
                </div>
 | 
			
		||||
              </uib-tab-heading>
 | 
			
		||||
              <application-events-datatable />
 | 
			
		||||
            </uib-tab>
 | 
			
		||||
 | 
			
		||||
            <uib-tab index="3" ng-if="ctrl.application.Yaml" select="ctrl.showEditor()" classes="btn-sm">
 | 
			
		||||
              <uib-tab-heading> <pr-icon icon="'code'"></pr-icon> YAML </uib-tab-heading>
 | 
			
		||||
              <div class="px-5" ng-if="ctrl.state.showEditorTab">
 | 
			
		||||
                <kube-yaml-inspector identifier="'application-yaml'" data="ctrl.application.Yaml" />
 | 
			
		||||
              </div>
 | 
			
		||||
            </uib-tab>
 | 
			
		||||
          </uib-tabset>
 | 
			
		||||
        </rd-widget-body>
 | 
			
		||||
      </rd-widget>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="row">
 | 
			
		||||
    <div class="col-sm-12">
 | 
			
		||||
      <application-details-widget></application-details-widget>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="row">
 | 
			
		||||
    <application-containers-datatable />
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -1,9 +0,0 @@
 | 
			
		|||
angular.module('portainer.kubernetes').component('kubernetesApplicationView', {
 | 
			
		||||
  templateUrl: './application.html',
 | 
			
		||||
  controller: 'KubernetesApplicationController',
 | 
			
		||||
  controllerAs: 'ctrl',
 | 
			
		||||
  bindings: {
 | 
			
		||||
    $transition$: '<',
 | 
			
		||||
    endpoint: '<',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -1,266 +0,0 @@
 | 
			
		|||
import angular from 'angular';
 | 
			
		||||
import _ from 'lodash-es';
 | 
			
		||||
import * as JsonPatch from 'fast-json-patch';
 | 
			
		||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  KubernetesApplicationDataAccessPolicies,
 | 
			
		||||
  KubernetesApplicationDeploymentTypes,
 | 
			
		||||
  KubernetesApplicationTypes,
 | 
			
		||||
  KubernetesDeploymentTypes,
 | 
			
		||||
} from 'Kubernetes/models/application/models';
 | 
			
		||||
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
 | 
			
		||||
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
 | 
			
		||||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
 | 
			
		||||
import { KubernetesPodContainerTypes } from 'Kubernetes/pod/models/index';
 | 
			
		||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
 | 
			
		||||
 | 
			
		||||
function computeTolerations(nodes, application) {
 | 
			
		||||
  const pod = application.Pods[0];
 | 
			
		||||
  _.forEach(nodes, (n) => {
 | 
			
		||||
    n.AcceptsApplication = true;
 | 
			
		||||
    n.Expanded = false;
 | 
			
		||||
    if (!pod) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    n.UnmetTaints = [];
 | 
			
		||||
    _.forEach(n.Taints, (t) => {
 | 
			
		||||
      const matchKeyMatchValueMatchEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Equal', Value: t.Value, Effect: t.Effect });
 | 
			
		||||
      const matchKeyAnyValueMatchEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Exists', Effect: t.Effect });
 | 
			
		||||
      const matchKeyMatchValueAnyEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Equal', Value: t.Value, Effect: '' });
 | 
			
		||||
      const matchKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Exists', Effect: '' });
 | 
			
		||||
      const anyKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: '', Operator: 'Exists', Effect: '' });
 | 
			
		||||
 | 
			
		||||
      if (!matchKeyMatchValueMatchEffect && !matchKeyAnyValueMatchEffect && !matchKeyMatchValueAnyEffect && !matchKeyAnyValueAnyEffect && !anyKeyAnyValueAnyEffect) {
 | 
			
		||||
        n.AcceptsApplication = false;
 | 
			
		||||
        n.UnmetTaints.push(t);
 | 
			
		||||
      } else {
 | 
			
		||||
        n.AcceptsApplication = true;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
  return nodes;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// For node requirement format depending on operator value
 | 
			
		||||
// see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#nodeselectorrequirement-v1-core
 | 
			
		||||
// Some operators require empty "values" field, some only one element in "values" field, etc
 | 
			
		||||
 | 
			
		||||
function computeAffinities(nodes, application) {
 | 
			
		||||
  if (!application.Pods || application.Pods.length === 0) {
 | 
			
		||||
    return nodes;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const pod = application.Pods[0];
 | 
			
		||||
  _.forEach(nodes, (n) => {
 | 
			
		||||
    if (pod.NodeSelector) {
 | 
			
		||||
      const patch = JsonPatch.compare(n.Labels, pod.NodeSelector);
 | 
			
		||||
      _.remove(patch, { op: 'remove' });
 | 
			
		||||
      n.UnmatchedNodeSelectorLabels = _.map(patch, (i) => {
 | 
			
		||||
        return { key: _.trimStart(i.path, '/'), value: i.value };
 | 
			
		||||
      });
 | 
			
		||||
      if (n.UnmatchedNodeSelectorLabels.length) {
 | 
			
		||||
        n.AcceptsApplication = false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (pod.Affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution) {
 | 
			
		||||
      const unmatchedTerms = _.map(pod.Affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms, (t) => {
 | 
			
		||||
        const unmatchedExpressions = _.map(t.matchExpressions, (e) => {
 | 
			
		||||
          const exists = {}.hasOwnProperty.call(n.Labels, e.key);
 | 
			
		||||
          const isIn = exists && _.includes(e.values, n.Labels[e.key]);
 | 
			
		||||
          if (
 | 
			
		||||
            (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS && exists) ||
 | 
			
		||||
            (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.DOES_NOT_EXIST && !exists) ||
 | 
			
		||||
            (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN && isIn) ||
 | 
			
		||||
            (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.NOT_IN && !isIn) ||
 | 
			
		||||
            (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.GREATER_THAN && exists && parseInt(n.Labels[e.key], 10) > parseInt(e.values[0], 10)) ||
 | 
			
		||||
            (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.LOWER_THAN && exists && parseInt(n.Labels[e.key], 10) < parseInt(e.values[0], 10))
 | 
			
		||||
          ) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
          return e;
 | 
			
		||||
        });
 | 
			
		||||
        return _.without(unmatchedExpressions, undefined);
 | 
			
		||||
      });
 | 
			
		||||
      _.remove(unmatchedTerms, (i) => i.length === 0);
 | 
			
		||||
      n.UnmatchedNodeAffinities = unmatchedTerms;
 | 
			
		||||
      if (n.UnmatchedNodeAffinities.length) {
 | 
			
		||||
        n.AcceptsApplication = false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  return nodes;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function computePlacements(nodes, application) {
 | 
			
		||||
  nodes = computeTolerations(nodes, application);
 | 
			
		||||
  nodes = computeAffinities(nodes, application);
 | 
			
		||||
  return nodes;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class KubernetesApplicationController {
 | 
			
		||||
  /* @ngInject */
 | 
			
		||||
  constructor(
 | 
			
		||||
    $async,
 | 
			
		||||
    $state,
 | 
			
		||||
    clipboard,
 | 
			
		||||
    Notifications,
 | 
			
		||||
    LocalStorage,
 | 
			
		||||
    KubernetesResourcePoolService,
 | 
			
		||||
    KubernetesApplicationService,
 | 
			
		||||
    KubernetesEventService,
 | 
			
		||||
    KubernetesStackService,
 | 
			
		||||
    KubernetesPodService,
 | 
			
		||||
    KubernetesNodeService,
 | 
			
		||||
    StackService
 | 
			
		||||
  ) {
 | 
			
		||||
    this.$async = $async;
 | 
			
		||||
    this.$state = $state;
 | 
			
		||||
    this.clipboard = clipboard;
 | 
			
		||||
    this.Notifications = Notifications;
 | 
			
		||||
    this.LocalStorage = LocalStorage;
 | 
			
		||||
    this.KubernetesResourcePoolService = KubernetesResourcePoolService;
 | 
			
		||||
    this.StackService = StackService;
 | 
			
		||||
 | 
			
		||||
    this.KubernetesApplicationService = KubernetesApplicationService;
 | 
			
		||||
    this.KubernetesEventService = KubernetesEventService;
 | 
			
		||||
    this.KubernetesStackService = KubernetesStackService;
 | 
			
		||||
    this.KubernetesPodService = KubernetesPodService;
 | 
			
		||||
    this.KubernetesNodeService = KubernetesNodeService;
 | 
			
		||||
 | 
			
		||||
    this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
 | 
			
		||||
    this.KubernetesApplicationTypes = KubernetesApplicationTypes;
 | 
			
		||||
    this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
 | 
			
		||||
 | 
			
		||||
    this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
 | 
			
		||||
    this.KubernetesServiceTypes = KubernetesServiceTypes;
 | 
			
		||||
    this.KubernetesPodContainerTypes = KubernetesPodContainerTypes;
 | 
			
		||||
 | 
			
		||||
    this.onInit = this.onInit.bind(this);
 | 
			
		||||
    this.getApplication = this.getApplication.bind(this);
 | 
			
		||||
    this.getApplicationAsync = this.getApplicationAsync.bind(this);
 | 
			
		||||
    this.getEvents = this.getEvents.bind(this);
 | 
			
		||||
    this.getEventsAsync = this.getEventsAsync.bind(this);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  selectTab(index) {
 | 
			
		||||
    this.LocalStorage.storeActiveTab('application', index);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  showEditor() {
 | 
			
		||||
    this.state.showEditorTab = true;
 | 
			
		||||
    this.selectTab(3);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isSystemNamespace() {
 | 
			
		||||
    return KubernetesNamespaceHelper.isSystemNamespace(this.application.ResourcePool);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  hasEventWarnings() {
 | 
			
		||||
    return this.state.eventWarningCount;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * EVENTS
 | 
			
		||||
   */
 | 
			
		||||
  async getEventsAsync() {
 | 
			
		||||
    try {
 | 
			
		||||
      this.state.eventsLoading = true;
 | 
			
		||||
      const events = await this.KubernetesEventService.get(this.state.params.namespace);
 | 
			
		||||
      this.events = _.filter(
 | 
			
		||||
        events,
 | 
			
		||||
        (event) =>
 | 
			
		||||
          event.Involved.uid === this.application.Id ||
 | 
			
		||||
          event.Involved.uid === this.application.ServiceId ||
 | 
			
		||||
          _.find(this.application.Pods, (pod) => pod.Id === event.Involved.uid) !== undefined
 | 
			
		||||
      );
 | 
			
		||||
      this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      this.Notifications.error('Failure', err, 'Unable to retrieve application related events');
 | 
			
		||||
    } finally {
 | 
			
		||||
      this.state.eventsLoading = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getEvents() {
 | 
			
		||||
    return this.$async(this.getEventsAsync);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * APPLICATION
 | 
			
		||||
   */
 | 
			
		||||
  async getApplicationAsync() {
 | 
			
		||||
    try {
 | 
			
		||||
      this.state.dataLoading = true;
 | 
			
		||||
      const [application, nodes] = await Promise.all([
 | 
			
		||||
        this.KubernetesApplicationService.get(this.state.params.namespace, this.state.params.name),
 | 
			
		||||
        this.KubernetesNodeService.get(),
 | 
			
		||||
      ]);
 | 
			
		||||
      this.application = application;
 | 
			
		||||
 | 
			
		||||
      this.placements = computePlacements(nodes, this.application);
 | 
			
		||||
      this.state.placementWarning = _.find(this.placements, { AcceptsApplication: true }) ? false : true;
 | 
			
		||||
 | 
			
		||||
      if (application.StackId) {
 | 
			
		||||
        const file = await this.StackService.getStackFile(application.StackId);
 | 
			
		||||
        this.stackFileContent = file;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      this.Notifications.error('Failure', err, 'Unable to retrieve application details');
 | 
			
		||||
    } finally {
 | 
			
		||||
      this.state.dataLoading = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getApplication() {
 | 
			
		||||
    return this.$async(this.getApplicationAsync);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async onInit() {
 | 
			
		||||
    this.limitedFeature = FeatureId.K8S_ROLLING_RESTART;
 | 
			
		||||
 | 
			
		||||
    this.state = {
 | 
			
		||||
      activeTab: 0,
 | 
			
		||||
      currentName: this.$state.$current.name,
 | 
			
		||||
      showEditorTab: false,
 | 
			
		||||
      DisplayedPanel: 'pods',
 | 
			
		||||
      eventsLoading: true,
 | 
			
		||||
      dataLoading: true,
 | 
			
		||||
      viewReady: false,
 | 
			
		||||
      params: {
 | 
			
		||||
        namespace: this.$transition$.params().namespace,
 | 
			
		||||
        name: this.$transition$.params().name,
 | 
			
		||||
      },
 | 
			
		||||
      appType: this.KubernetesDeploymentTypes.APPLICATION_FORM,
 | 
			
		||||
      eventWarningCount: 0,
 | 
			
		||||
      placementWarning: false,
 | 
			
		||||
      expandedNote: false,
 | 
			
		||||
      publicUrl: this.endpoint.PublicURL,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    this.state.activeTab = this.LocalStorage.getActiveTab('application');
 | 
			
		||||
 | 
			
		||||
    this.formValues = {
 | 
			
		||||
      Note: '',
 | 
			
		||||
      SelectedRevision: undefined,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    await this.getApplication();
 | 
			
		||||
    await this.getEvents();
 | 
			
		||||
    this.state.viewReady = true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  $onInit() {
 | 
			
		||||
    return this.$async(this.onInit);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  $onDestroy() {
 | 
			
		||||
    if (this.state.currentName !== this.$state.$current.name) {
 | 
			
		||||
      this.LocalStorage.storeActiveTab('application', 0);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default KubernetesApplicationController;
 | 
			
		||||
angular.module('portainer.kubernetes').controller('KubernetesApplicationController', KubernetesApplicationController);
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +121,10 @@ export const ngModule = angular
 | 
			
		|||
    r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className'])
 | 
			
		||||
  )
 | 
			
		||||
  .component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin']))
 | 
			
		||||
  .component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, []))
 | 
			
		||||
  .component(
 | 
			
		||||
    'reactQueryDevTools',
 | 
			
		||||
    r2a(withReactQuery(ReactQueryDevtoolsWrapper), [])
 | 
			
		||||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
    'dashboardItem',
 | 
			
		||||
    r2a(DashboardItem, [
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
import { Meta } from '@storybook/react';
 | 
			
		||||
 | 
			
		||||
import { Badge, Props } from './Badge';
 | 
			
		||||
import { Badge, BadgeType, Props } from './Badge';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  component: Badge,
 | 
			
		||||
| 
						 | 
				
			
			@ -17,11 +17,15 @@ export default {
 | 
			
		|||
 | 
			
		||||
// : JSX.IntrinsicAttributes & PropsWithChildren<Props>
 | 
			
		||||
function Template({ type = 'success' }: Props) {
 | 
			
		||||
  const message = {
 | 
			
		||||
  const message: Record<BadgeType, string> = {
 | 
			
		||||
    success: 'success badge',
 | 
			
		||||
    danger: 'danger badge',
 | 
			
		||||
    warn: 'warn badge',
 | 
			
		||||
    info: 'info badge',
 | 
			
		||||
    successSecondary: 'successSecondary badge',
 | 
			
		||||
    dangerSecondary: 'dangerSecondary badge',
 | 
			
		||||
    warnSecondary: 'warnSecondary badge',
 | 
			
		||||
    infoSecondary: 'infoSecondary badge',
 | 
			
		||||
  };
 | 
			
		||||
  return <Badge type={type}>{message[type]}</Badge>;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,60 @@
 | 
			
		|||
import clsx from 'clsx';
 | 
			
		||||
import { PropsWithChildren } from 'react';
 | 
			
		||||
 | 
			
		||||
export type BadgeType = 'success' | 'danger' | 'warn' | 'info';
 | 
			
		||||
export type BadgeType =
 | 
			
		||||
  | 'success'
 | 
			
		||||
  | 'danger'
 | 
			
		||||
  | 'warn'
 | 
			
		||||
  | 'info'
 | 
			
		||||
  | 'successSecondary'
 | 
			
		||||
  | 'dangerSecondary'
 | 
			
		||||
  | 'warnSecondary'
 | 
			
		||||
  | 'infoSecondary';
 | 
			
		||||
 | 
			
		||||
// the classes are typed in full because tailwind doesn't render the interpolated classes
 | 
			
		||||
const typeClasses: Record<BadgeType, string> = {
 | 
			
		||||
  success: clsx(
 | 
			
		||||
    `text-success-9 bg-success-2`,
 | 
			
		||||
    `th-dark:text-success-3 th-dark:bg-success-10`,
 | 
			
		||||
    `th-highcontrast:text-success-3 th-highcontrast:bg-success-10`
 | 
			
		||||
  ),
 | 
			
		||||
  warn: clsx(
 | 
			
		||||
    `text-warning-9 bg-warning-2`,
 | 
			
		||||
    `th-dark:text-warning-3 th-dark:bg-warning-10`,
 | 
			
		||||
    `th-highcontrast:text-warning-3 th-highcontrast:bg-warning-10`
 | 
			
		||||
  ),
 | 
			
		||||
  danger: clsx(
 | 
			
		||||
    `text-error-9 bg-error-2`,
 | 
			
		||||
    `th-dark:text-error-3 th-dark:bg-error-10`,
 | 
			
		||||
    `th-highcontrast:text-error-3 th-highcontrast:bg-error-10`
 | 
			
		||||
  ),
 | 
			
		||||
  info: clsx(
 | 
			
		||||
    `text-blue-9 bg-blue-2`,
 | 
			
		||||
    `th-dark:text-blue-3 th-dark:bg-blue-10`,
 | 
			
		||||
    `th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
 | 
			
		||||
  ),
 | 
			
		||||
  // the secondary classes are a bit darker in light mode and a bit lighter in dark mode
 | 
			
		||||
  successSecondary: clsx(
 | 
			
		||||
    `text-success-9 bg-success-3`,
 | 
			
		||||
    `th-dark:text-success-3 th-dark:bg-success-9`,
 | 
			
		||||
    `th-highcontrast:text-success-3 th-highcontrast:bg-success-9`
 | 
			
		||||
  ),
 | 
			
		||||
  warnSecondary: clsx(
 | 
			
		||||
    `text-warning-9 bg-warning-3`,
 | 
			
		||||
    `th-dark:text-warning-3 th-dark:bg-warning-9`,
 | 
			
		||||
    `th-highcontrast:text-warning-3 th-highcontrast:bg-warning-9`
 | 
			
		||||
  ),
 | 
			
		||||
  dangerSecondary: clsx(
 | 
			
		||||
    `text-error-9 bg-error-3`,
 | 
			
		||||
    `th-dark:text-error-3 th-dark:bg-error-9`,
 | 
			
		||||
    `th-highcontrast:text-error-3 th-highcontrast:bg-error-9`
 | 
			
		||||
  ),
 | 
			
		||||
  infoSecondary: clsx(
 | 
			
		||||
    `text-blue-9 bg-blue-3`,
 | 
			
		||||
    `th-dark:text-blue-3 th-dark:bg-blue-9`,
 | 
			
		||||
    `th-highcontrast:text-blue-3 th-highcontrast:bg-blue-9`
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface Props {
 | 
			
		||||
  type?: BadgeType;
 | 
			
		||||
| 
						 | 
				
			
			@ -10,50 +63,17 @@ export interface Props {
 | 
			
		|||
 | 
			
		||||
// this component is used in tables and lists in portainer. It looks like this:
 | 
			
		||||
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?node-id=76%3A2
 | 
			
		||||
export function Badge({ type, className, children }: PropsWithChildren<Props>) {
 | 
			
		||||
export function Badge({
 | 
			
		||||
  type = 'info',
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
}: PropsWithChildren<Props>) {
 | 
			
		||||
  const baseClasses =
 | 
			
		||||
    'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
 | 
			
		||||
  const typeClasses = getClasses(type);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <span className={clsx(baseClasses, typeClasses, className)}>
 | 
			
		||||
    <span className={clsx(baseClasses, typeClasses[type], className)}>
 | 
			
		||||
      {children}
 | 
			
		||||
    </span>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// the classes are typed in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes
 | 
			
		||||
function getClasses(type: BadgeType | undefined) {
 | 
			
		||||
  switch (type) {
 | 
			
		||||
    case 'success':
 | 
			
		||||
      return clsx(
 | 
			
		||||
        `text-success-9 bg-success-2`,
 | 
			
		||||
        `th-dark:text-success-3 th-dark:bg-success-10`,
 | 
			
		||||
        `th-highcontrast:text-success-3 th-highcontrast:bg-success-10`
 | 
			
		||||
      );
 | 
			
		||||
    case 'warn':
 | 
			
		||||
      return clsx(
 | 
			
		||||
        `text-warning-9 bg-warning-2`,
 | 
			
		||||
        `th-dark:text-warning-3 th-dark:bg-warning-10`,
 | 
			
		||||
        `th-highcontrast:text-warning-3 th-highcontrast:bg-warning-10`
 | 
			
		||||
      );
 | 
			
		||||
    case 'danger':
 | 
			
		||||
      return clsx(
 | 
			
		||||
        `text-error-9 bg-error-2`,
 | 
			
		||||
        `th-dark:text-error-3 th-dark:bg-error-10`,
 | 
			
		||||
        `th-highcontrast:text-error-3 th-highcontrast:bg-error-10`
 | 
			
		||||
      );
 | 
			
		||||
    case 'info':
 | 
			
		||||
      return clsx(
 | 
			
		||||
        `text-blue-9 bg-blue-2`,
 | 
			
		||||
        `th-dark:text-blue-3 th-dark:bg-blue-10`,
 | 
			
		||||
        `th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
 | 
			
		||||
      );
 | 
			
		||||
    default:
 | 
			
		||||
      return clsx(
 | 
			
		||||
        `text-blue-9 bg-blue-2`,
 | 
			
		||||
        `th-dark:text-blue-3 th-dark:bg-blue-10`,
 | 
			
		||||
        `th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
 | 
			
		||||
      );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -90,6 +90,7 @@ export function CodeEditor({
 | 
			
		|||
        </div>
 | 
			
		||||
 | 
			
		||||
        <CopyButton
 | 
			
		||||
          fadeDelay={2500}
 | 
			
		||||
          copyText={value}
 | 
			
		||||
          color="link"
 | 
			
		||||
          className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,17 +4,31 @@ import clsx from 'clsx';
 | 
			
		|||
 | 
			
		||||
import { Icon } from '@@/Icon';
 | 
			
		||||
 | 
			
		||||
type Size = 'xs' | 'sm' | 'md';
 | 
			
		||||
 | 
			
		||||
export type Props = {
 | 
			
		||||
  className: string;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  size?: Size;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const sizeStyles: Record<Size, string> = {
 | 
			
		||||
  xs: 'text-xs',
 | 
			
		||||
  sm: 'text-sm',
 | 
			
		||||
  md: 'text-md',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function InlineLoader({
 | 
			
		||||
  children,
 | 
			
		||||
  className,
 | 
			
		||||
  size = 'sm',
 | 
			
		||||
}: PropsWithChildren<Props>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx('text-muted flex items-center gap-2 text-sm', className)}
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        'text-muted flex items-center gap-2',
 | 
			
		||||
        className,
 | 
			
		||||
        sizeStyles[size]
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      <Icon icon={Loader2} className="animate-spin-slow" />
 | 
			
		||||
      {children}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
import { useEffect, useState } from 'react';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
import { ChevronUp, ChevronRight, Edit } from 'lucide-react';
 | 
			
		||||
 | 
			
		||||
import { Button } from '@@/buttons';
 | 
			
		||||
import { FormError } from '@@/form-components/FormError';
 | 
			
		||||
 | 
			
		||||
export type Props = {
 | 
			
		||||
  onChange: (value: string) => void;
 | 
			
		||||
  defaultIsOpen?: boolean;
 | 
			
		||||
  value?: string;
 | 
			
		||||
  labelClass?: string;
 | 
			
		||||
  inputClass?: string;
 | 
			
		||||
  isRequired?: boolean;
 | 
			
		||||
  minLength?: number;
 | 
			
		||||
  isExpandable?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function Note({
 | 
			
		||||
  onChange,
 | 
			
		||||
  defaultIsOpen,
 | 
			
		||||
  value,
 | 
			
		||||
  labelClass = 'col-sm-12 mb-2',
 | 
			
		||||
  inputClass = 'col-sm-12',
 | 
			
		||||
  isRequired,
 | 
			
		||||
  minLength,
 | 
			
		||||
  isExpandable,
 | 
			
		||||
}: Props) {
 | 
			
		||||
  const [isNoteOpen, setIsNoteOpen] = useState(defaultIsOpen || isRequired);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setIsNoteOpen(defaultIsOpen || isRequired);
 | 
			
		||||
  }, [defaultIsOpen, isRequired]);
 | 
			
		||||
 | 
			
		||||
  let error = '';
 | 
			
		||||
  if (isRequired && minLength && (!value || value.length < minLength)) {
 | 
			
		||||
    error = `You have entered ${value ? value.length : 0} of the ${minLength} ${
 | 
			
		||||
      minLength === 1 ? 'character' : 'characters'
 | 
			
		||||
    } minimum required.`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="form-group">
 | 
			
		||||
      <div className={clsx('vertical-center', labelClass)}>
 | 
			
		||||
        {isExpandable && (
 | 
			
		||||
          <Button
 | 
			
		||||
            size="small"
 | 
			
		||||
            type="button"
 | 
			
		||||
            color="none"
 | 
			
		||||
            data-cy="k8sAppDetail-expandNoteButton"
 | 
			
		||||
            onClick={() => setIsNoteOpen(!isNoteOpen)}
 | 
			
		||||
            className="!m-0 !p-0"
 | 
			
		||||
          >
 | 
			
		||||
            {isNoteOpen ? <ChevronUp /> : <ChevronRight />} <Edit />
 | 
			
		||||
            <span className={isRequired ? 'required' : ''}>Note</span>
 | 
			
		||||
          </Button>
 | 
			
		||||
        )}
 | 
			
		||||
        {!isExpandable && (
 | 
			
		||||
          <span
 | 
			
		||||
            className={clsx(
 | 
			
		||||
              'control-label text-left',
 | 
			
		||||
              isRequired ? 'required' : ''
 | 
			
		||||
            )}
 | 
			
		||||
          >
 | 
			
		||||
            Note
 | 
			
		||||
          </span>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {isNoteOpen && (
 | 
			
		||||
        <div className={inputClass}>
 | 
			
		||||
          <textarea
 | 
			
		||||
            className="form-control resize-y"
 | 
			
		||||
            name="application_note"
 | 
			
		||||
            id="application_note"
 | 
			
		||||
            value={value}
 | 
			
		||||
            onChange={(e) => onChange(e.target.value)}
 | 
			
		||||
            rows={5}
 | 
			
		||||
            placeholder="Enter a note about this application..."
 | 
			
		||||
            minLength={minLength}
 | 
			
		||||
          />
 | 
			
		||||
          {error && (
 | 
			
		||||
            <FormError className="error-inline mt-2">{error}</FormError>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export { Note, type Props } from './Note';
 | 
			
		||||
| 
						 | 
				
			
			@ -6,7 +6,7 @@ import { Icon } from '@@/Icon';
 | 
			
		|||
import { Link } from '@@/Link';
 | 
			
		||||
 | 
			
		||||
export interface Tab {
 | 
			
		||||
  name: string;
 | 
			
		||||
  name: ReactNode;
 | 
			
		||||
  icon: ReactNode;
 | 
			
		||||
  widget: ReactNode;
 | 
			
		||||
  selectedTabParam: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -17,6 +17,7 @@ interface Props {
 | 
			
		|||
  tabs: Tab[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?type=design&node-id=148-2676&mode=design&t=JKyBWBupeC5WADk6-0
 | 
			
		||||
export function WidgetTabs({ currentTabIndex, tabs }: Props) {
 | 
			
		||||
  // ensure that the selectedTab param is always valid
 | 
			
		||||
  const invalidQueryParamValue = tabs.every(
 | 
			
		||||
| 
						 | 
				
			
			@ -37,10 +38,13 @@ export function WidgetTabs({ currentTabIndex, tabs }: Props) {
 | 
			
		|||
              params={{ tab: tabs[index].selectedTabParam }}
 | 
			
		||||
              key={index}
 | 
			
		||||
              className={clsx(
 | 
			
		||||
                'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2',
 | 
			
		||||
                currentTabIndex === index
 | 
			
		||||
                  ? 'border-blue-8  text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6'
 | 
			
		||||
                  : 'border-transparent'
 | 
			
		||||
                'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2 hover:no-underline',
 | 
			
		||||
                {
 | 
			
		||||
                  'border-blue-8  text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6':
 | 
			
		||||
                    currentTabIndex === index,
 | 
			
		||||
                  'border-transparent text-gray-7 hover:text-gray-8 th-highcontrast:text-gray-6 hover:th-highcontrast:text-gray-5 th-dark:text-gray-6 hover:th-dark:text-gray-5':
 | 
			
		||||
                    currentTabIndex !== index,
 | 
			
		||||
                }
 | 
			
		||||
              )}
 | 
			
		||||
            >
 | 
			
		||||
              <Icon icon={icon} />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,44 @@
 | 
			
		|||
import { InlineLoader } from '@@/InlineLoader';
 | 
			
		||||
import { Widget } from '@@/Widget/Widget';
 | 
			
		||||
import { WidgetBody } from '@@/Widget';
 | 
			
		||||
 | 
			
		||||
import { YAMLInspector } from '../../../components/YAMLInspector';
 | 
			
		||||
 | 
			
		||||
import { useApplicationYAML } from './useApplicationYAML';
 | 
			
		||||
 | 
			
		||||
// the yaml currently has the yaml from the app, related services and horizontal pod autoscalers
 | 
			
		||||
// TODO: this could be extended to include other related resources like ingresses, etc.
 | 
			
		||||
export function ApplicationYAMLEditor() {
 | 
			
		||||
  const { fullApplicationYaml, isApplicationYAMLLoading } =
 | 
			
		||||
    useApplicationYAML();
 | 
			
		||||
 | 
			
		||||
  if (isApplicationYAMLLoading) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="row">
 | 
			
		||||
        <div className="col-sm-12">
 | 
			
		||||
          <Widget>
 | 
			
		||||
            <WidgetBody>
 | 
			
		||||
              <InlineLoader>Loading application YAML...</InlineLoader>
 | 
			
		||||
            </WidgetBody>
 | 
			
		||||
          </Widget>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="row">
 | 
			
		||||
      <div className="col-sm-12">
 | 
			
		||||
        <Widget>
 | 
			
		||||
          <WidgetBody>
 | 
			
		||||
            <YAMLInspector
 | 
			
		||||
              identifier="application-yaml"
 | 
			
		||||
              data={fullApplicationYaml}
 | 
			
		||||
              hideMessage
 | 
			
		||||
            />
 | 
			
		||||
          </WidgetBody>
 | 
			
		||||
        </Widget>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,86 @@
 | 
			
		|||
import { useCurrentStateAndParams } from '@uirouter/react';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
 | 
			
		||||
import { useServicesQuery } from '@/react/kubernetes/services/service';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  useApplication,
 | 
			
		||||
  useApplicationHorizontalPodAutoscaler,
 | 
			
		||||
  useApplicationServices,
 | 
			
		||||
} from '../../application.queries';
 | 
			
		||||
import { useHorizontalAutoScalarQuery } from '../../autoscaling.service';
 | 
			
		||||
 | 
			
		||||
export function useApplicationYAML() {
 | 
			
		||||
  const {
 | 
			
		||||
    params: {
 | 
			
		||||
      namespace,
 | 
			
		||||
      name,
 | 
			
		||||
      'resource-type': resourceType,
 | 
			
		||||
      endpointId: environmentId,
 | 
			
		||||
    },
 | 
			
		||||
  } = useCurrentStateAndParams();
 | 
			
		||||
  // find the application and the yaml for it
 | 
			
		||||
  const { data: application, ...applicationQuery } = useApplication(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    resourceType
 | 
			
		||||
  );
 | 
			
		||||
  const { data: applicationYAML, ...applicationYAMLQuery } =
 | 
			
		||||
    useApplication<string>(environmentId, namespace, name, resourceType, {
 | 
			
		||||
      yaml: true,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  // find the matching services, then get the yaml for them
 | 
			
		||||
  const { data: services, ...servicesQuery } = useApplicationServices(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    application
 | 
			
		||||
  );
 | 
			
		||||
  const serviceNames =
 | 
			
		||||
    services?.flatMap((service) => service.metadata?.name || []) || [];
 | 
			
		||||
  const { data: servicesYAML, ...servicesYAMLQuery } = useServicesQuery<string>(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    serviceNames,
 | 
			
		||||
    { yaml: true }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // find the matching autoscalar, then get the yaml for it
 | 
			
		||||
  const { data: autoScalar, ...autoScalarsQuery } =
 | 
			
		||||
    useApplicationHorizontalPodAutoscaler(
 | 
			
		||||
      environmentId,
 | 
			
		||||
      namespace,
 | 
			
		||||
      name,
 | 
			
		||||
      application
 | 
			
		||||
    );
 | 
			
		||||
  const { data: autoScalarYAML, ...autoScalarYAMLQuery } =
 | 
			
		||||
    useHorizontalAutoScalarQuery<string>(
 | 
			
		||||
      environmentId,
 | 
			
		||||
      namespace,
 | 
			
		||||
      autoScalar?.metadata?.name || '',
 | 
			
		||||
      { yaml: true }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  const fullApplicationYaml = useMemo(() => {
 | 
			
		||||
    const yamlArray = [
 | 
			
		||||
      applicationYAML,
 | 
			
		||||
      ...(servicesYAML || []),
 | 
			
		||||
      autoScalarYAML,
 | 
			
		||||
    ].flatMap((yaml) => yaml || []);
 | 
			
		||||
 | 
			
		||||
    const yamlString = yamlArray.join('\n---\n');
 | 
			
		||||
    return yamlString;
 | 
			
		||||
  }, [applicationYAML, autoScalarYAML, servicesYAML]);
 | 
			
		||||
 | 
			
		||||
  const isApplicationYAMLLoading =
 | 
			
		||||
    applicationQuery.isLoading ||
 | 
			
		||||
    servicesQuery.isLoading ||
 | 
			
		||||
    autoScalarsQuery.isLoading ||
 | 
			
		||||
    applicationYAMLQuery.isLoading ||
 | 
			
		||||
    servicesYAMLQuery.isLoading ||
 | 
			
		||||
    autoScalarYAMLQuery.isLoading;
 | 
			
		||||
 | 
			
		||||
  return { fullApplicationYaml, isApplicationYAMLLoading };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -58,7 +58,7 @@ export function ApplicationContainersDatatable() {
 | 
			
		|||
      emptyContentLabel="No containers found"
 | 
			
		||||
      title="Application containers"
 | 
			
		||||
      titleIcon={Server}
 | 
			
		||||
      getRowId={(row) => row.name}
 | 
			
		||||
      getRowId={(row) => row.podName} // use pod name because it's unique (name is not unique)
 | 
			
		||||
      disableSelect
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,132 @@
 | 
			
		|||
import { AlertTriangle, Code, History, Minimize2 } from 'lucide-react';
 | 
			
		||||
import { useCurrentStateAndParams } from '@uirouter/react';
 | 
			
		||||
 | 
			
		||||
import LaptopCode from '@/assets/ico/laptop-code.svg?c';
 | 
			
		||||
 | 
			
		||||
import { PageHeader } from '@@/PageHeader';
 | 
			
		||||
import { Tab, WidgetTabs, findSelectedTabIndex } from '@@/Widget/WidgetTabs';
 | 
			
		||||
import { Icon } from '@@/Icon';
 | 
			
		||||
import { Badge } from '@@/Badge';
 | 
			
		||||
 | 
			
		||||
import { EventsDatatable } from '../../components/KubernetesEventsDatatable';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  PlacementsDatatable,
 | 
			
		||||
  usePlacementTableData,
 | 
			
		||||
  usePlacementTableState,
 | 
			
		||||
} from './PlacementsDatatable';
 | 
			
		||||
import { ApplicationDetailsWidget } from './ApplicationDetailsWidget';
 | 
			
		||||
import { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
 | 
			
		||||
import { ApplicationContainersDatatable } from './ApplicationContainersDatatable';
 | 
			
		||||
import {
 | 
			
		||||
  useApplicationEventsTableData,
 | 
			
		||||
  useApplicationEventsTableState,
 | 
			
		||||
} from './useApplicationEventsTableData';
 | 
			
		||||
import { ApplicationYAMLEditor } from './AppYAMLEditor/ApplicationYAMLEditor';
 | 
			
		||||
import { useApplicationYAML } from './AppYAMLEditor/useApplicationYAML';
 | 
			
		||||
 | 
			
		||||
export function ApplicationDetailsView() {
 | 
			
		||||
  const stateAndParams = useCurrentStateAndParams();
 | 
			
		||||
  const {
 | 
			
		||||
    params: { namespace, name },
 | 
			
		||||
  } = stateAndParams;
 | 
			
		||||
 | 
			
		||||
  // placements table data
 | 
			
		||||
  const { placementsData, isPlacementsTableLoading, hasPlacementWarning } =
 | 
			
		||||
    usePlacementTableData();
 | 
			
		||||
  const placementsTableState = usePlacementTableState();
 | 
			
		||||
 | 
			
		||||
  // events table data
 | 
			
		||||
  const { appEventsData, appEventWarningCount, isAppEventsTableLoading } =
 | 
			
		||||
    useApplicationEventsTableData();
 | 
			
		||||
  const appEventsTableState = useApplicationEventsTableState();
 | 
			
		||||
 | 
			
		||||
  // load app yaml data early to load from cache later
 | 
			
		||||
  useApplicationYAML();
 | 
			
		||||
 | 
			
		||||
  const tabs: Tab[] = [
 | 
			
		||||
    {
 | 
			
		||||
      name: 'Application',
 | 
			
		||||
      icon: LaptopCode,
 | 
			
		||||
      widget: <ApplicationSummaryWidget />,
 | 
			
		||||
      selectedTabParam: 'application',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: (
 | 
			
		||||
        <div className="flex items-center gap-x-2">
 | 
			
		||||
          Placement
 | 
			
		||||
          {hasPlacementWarning && (
 | 
			
		||||
            <Badge type="warnSecondary">
 | 
			
		||||
              <Icon icon={AlertTriangle} className="!mr-1" />1
 | 
			
		||||
            </Badge>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      ),
 | 
			
		||||
      icon: Minimize2,
 | 
			
		||||
      widget: (
 | 
			
		||||
        <PlacementsDatatable
 | 
			
		||||
          hasPlacementWarning={hasPlacementWarning}
 | 
			
		||||
          tableState={placementsTableState}
 | 
			
		||||
          dataset={placementsData}
 | 
			
		||||
          isLoading={isPlacementsTableLoading}
 | 
			
		||||
        />
 | 
			
		||||
      ),
 | 
			
		||||
      selectedTabParam: 'placement',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: (
 | 
			
		||||
        <div className="flex items-center gap-x-2">
 | 
			
		||||
          Events
 | 
			
		||||
          {appEventWarningCount >= 1 && (
 | 
			
		||||
            <Badge type="warnSecondary">
 | 
			
		||||
              <Icon icon={AlertTriangle} className="!mr-1" />
 | 
			
		||||
              {appEventWarningCount}
 | 
			
		||||
            </Badge>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      ),
 | 
			
		||||
      icon: History,
 | 
			
		||||
      widget: (
 | 
			
		||||
        <EventsDatatable
 | 
			
		||||
          dataset={appEventsData}
 | 
			
		||||
          tableState={appEventsTableState}
 | 
			
		||||
          isLoading={isAppEventsTableLoading}
 | 
			
		||||
          data-cy="k8sAppDetail-eventsTable"
 | 
			
		||||
        />
 | 
			
		||||
      ),
 | 
			
		||||
      selectedTabParam: 'events',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'YAML',
 | 
			
		||||
      icon: Code,
 | 
			
		||||
      widget: <ApplicationYAMLEditor />,
 | 
			
		||||
      selectedTabParam: 'YAML',
 | 
			
		||||
    },
 | 
			
		||||
  ];
 | 
			
		||||
  const currentTabIndex = findSelectedTabIndex(stateAndParams, tabs);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PageHeader
 | 
			
		||||
        title="Application details"
 | 
			
		||||
        breadcrumbs={[
 | 
			
		||||
          { label: 'Namespaces', link: 'kubernetes.resourcePools' },
 | 
			
		||||
          {
 | 
			
		||||
            label: namespace,
 | 
			
		||||
            link: 'kubernetes.resourcePools.resourcePool',
 | 
			
		||||
            linkParams: { id: namespace },
 | 
			
		||||
          },
 | 
			
		||||
          { label: 'Applications', link: 'kubernetes.applications' },
 | 
			
		||||
          name,
 | 
			
		||||
        ]}
 | 
			
		||||
        reload
 | 
			
		||||
      />
 | 
			
		||||
      <>
 | 
			
		||||
        <WidgetTabs tabs={tabs} currentTabIndex={currentTabIndex} />
 | 
			
		||||
        {tabs[currentTabIndex].widget}
 | 
			
		||||
        <ApplicationDetailsWidget />
 | 
			
		||||
        <ApplicationContainersDatatable />
 | 
			
		||||
      </>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -7,7 +7,7 @@ import { TextTip } from '@@/Tip/TextTip';
 | 
			
		|||
import { Tooltip } from '@@/Tip/Tooltip';
 | 
			
		||||
 | 
			
		||||
import { Application } from '../../types';
 | 
			
		||||
import { useApplicationHorizontalPodAutoscalers } from '../../application.queries';
 | 
			
		||||
import { useApplicationHorizontalPodAutoscaler } from '../../application.queries';
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  environmentId: EnvironmentId;
 | 
			
		||||
| 
						 | 
				
			
			@ -22,7 +22,7 @@ export function ApplicationAutoScalingTable({
 | 
			
		|||
  appName,
 | 
			
		||||
  app,
 | 
			
		||||
}: Props) {
 | 
			
		||||
  const { data: appAutoScalar } = useApplicationHorizontalPodAutoscalers(
 | 
			
		||||
  const { data: appAutoScalar } = useApplicationHorizontalPodAutoscaler(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    appName,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -40,8 +40,12 @@ export function ApplicationDetailsWidget() {
 | 
			
		|||
  } = stateAndParams;
 | 
			
		||||
 | 
			
		||||
  // get app info
 | 
			
		||||
  const appQuery = useApplication(environmentId, namespace, name, resourceType);
 | 
			
		||||
  const app = appQuery.data;
 | 
			
		||||
  const { data: app } = useApplication(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    resourceType
 | 
			
		||||
  );
 | 
			
		||||
  const externalApp = app && isExternalApplication(app);
 | 
			
		||||
  const appStackId = Number(app?.metadata?.labels?.[appStackIdLabel]);
 | 
			
		||||
  const appStackFileQuery = useStackFile(appStackId);
 | 
			
		||||
| 
						 | 
				
			
			@ -53,90 +57,94 @@ export function ApplicationDetailsWidget() {
 | 
			
		|||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Widget>
 | 
			
		||||
      <WidgetBody>
 | 
			
		||||
        {!isSystemNamespace(namespace) && (
 | 
			
		||||
          <div className="mb-4 flex flex-wrap gap-2">
 | 
			
		||||
            <Authorized authorizations="K8sApplicationDetailsW">
 | 
			
		||||
              <Link to="kubernetes.applications.application.edit">
 | 
			
		||||
                <Button
 | 
			
		||||
                  type="button"
 | 
			
		||||
                  color="light"
 | 
			
		||||
                  size="small"
 | 
			
		||||
                  className="hover:decoration-none !ml-0"
 | 
			
		||||
                  data-cy="k8sAppDetail-editAppButton"
 | 
			
		||||
                >
 | 
			
		||||
                  <Icon icon={Pencil} className="mr-1" />
 | 
			
		||||
                  {externalApp
 | 
			
		||||
                    ? 'Edit external application'
 | 
			
		||||
                    : 'Edit this application'}
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Link>
 | 
			
		||||
            </Authorized>
 | 
			
		||||
            {!applicationIsKind<Pod>('Pod', app) && (
 | 
			
		||||
              <>
 | 
			
		||||
                <RestartApplicationButton />
 | 
			
		||||
                <RedeployApplicationButton
 | 
			
		||||
                  environmentId={environmentId}
 | 
			
		||||
                  namespace={namespace}
 | 
			
		||||
                  appName={name}
 | 
			
		||||
                  app={app}
 | 
			
		||||
                />
 | 
			
		||||
              </>
 | 
			
		||||
    <div className="row">
 | 
			
		||||
      <div className="col-sm-12">
 | 
			
		||||
        <Widget>
 | 
			
		||||
          <WidgetBody>
 | 
			
		||||
            {!isSystemNamespace(namespace) && (
 | 
			
		||||
              <div className="mb-4 flex flex-wrap gap-2">
 | 
			
		||||
                <Authorized authorizations="K8sApplicationDetailsW">
 | 
			
		||||
                  <Link to="kubernetes.applications.application.edit">
 | 
			
		||||
                    <Button
 | 
			
		||||
                      type="button"
 | 
			
		||||
                      color="light"
 | 
			
		||||
                      size="small"
 | 
			
		||||
                      className="hover:decoration-none !ml-0"
 | 
			
		||||
                      data-cy="k8sAppDetail-editAppButton"
 | 
			
		||||
                    >
 | 
			
		||||
                      <Icon icon={Pencil} className="mr-1" />
 | 
			
		||||
                      {externalApp
 | 
			
		||||
                        ? 'Edit external application'
 | 
			
		||||
                        : 'Edit this application'}
 | 
			
		||||
                    </Button>
 | 
			
		||||
                  </Link>
 | 
			
		||||
                </Authorized>
 | 
			
		||||
                {!applicationIsKind<Pod>('Pod', app) && (
 | 
			
		||||
                  <>
 | 
			
		||||
                    <RestartApplicationButton />
 | 
			
		||||
                    <RedeployApplicationButton
 | 
			
		||||
                      environmentId={environmentId}
 | 
			
		||||
                      namespace={namespace}
 | 
			
		||||
                      appName={name}
 | 
			
		||||
                      app={app}
 | 
			
		||||
                    />
 | 
			
		||||
                  </>
 | 
			
		||||
                )}
 | 
			
		||||
                {!externalApp && (
 | 
			
		||||
                  <RollbackApplicationButton
 | 
			
		||||
                    environmentId={environmentId}
 | 
			
		||||
                    namespace={namespace}
 | 
			
		||||
                    appName={name}
 | 
			
		||||
                    app={app}
 | 
			
		||||
                  />
 | 
			
		||||
                )}
 | 
			
		||||
                {appStackFileQuery.data && (
 | 
			
		||||
                  <Link
 | 
			
		||||
                    to="kubernetes.templates.custom.new"
 | 
			
		||||
                    params={{
 | 
			
		||||
                      fileContent: appStackFileQuery.data.StackFileContent,
 | 
			
		||||
                    }}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Button
 | 
			
		||||
                      type="button"
 | 
			
		||||
                      color="primary"
 | 
			
		||||
                      size="small"
 | 
			
		||||
                      className="hover:decoration-none !ml-0"
 | 
			
		||||
                      data-cy="k8sAppDetail-createCustomTemplateButton"
 | 
			
		||||
                    >
 | 
			
		||||
                      <Icon icon={Plus} className="mr-1" />
 | 
			
		||||
                      Create template from application
 | 
			
		||||
                    </Button>
 | 
			
		||||
                  </Link>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
            {!externalApp && (
 | 
			
		||||
              <RollbackApplicationButton
 | 
			
		||||
                environmentId={environmentId}
 | 
			
		||||
                namespace={namespace}
 | 
			
		||||
                appName={name}
 | 
			
		||||
                app={app}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
            {appStackFileQuery.data && (
 | 
			
		||||
              <Link
 | 
			
		||||
                to="kubernetes.templates.custom.new"
 | 
			
		||||
                params={{
 | 
			
		||||
                  fileContent: appStackFileQuery.data.StackFileContent,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <Button
 | 
			
		||||
                  type="button"
 | 
			
		||||
                  color="primary"
 | 
			
		||||
                  size="small"
 | 
			
		||||
                  className="hover:decoration-none !ml-0"
 | 
			
		||||
                  data-cy="k8sAppDetail-createCustomTemplateButton"
 | 
			
		||||
                >
 | 
			
		||||
                  <Icon icon={Plus} className="mr-1" />
 | 
			
		||||
                  Create template from application
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Link>
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        <ApplicationServicesTable
 | 
			
		||||
          environmentId={environmentId}
 | 
			
		||||
          appServices={appServices}
 | 
			
		||||
        />
 | 
			
		||||
        <ApplicationIngressesTable
 | 
			
		||||
          appServices={appServices}
 | 
			
		||||
          environmentId={environmentId}
 | 
			
		||||
          namespace={namespace}
 | 
			
		||||
        />
 | 
			
		||||
        <ApplicationAutoScalingTable
 | 
			
		||||
          environmentId={environmentId}
 | 
			
		||||
          namespace={namespace}
 | 
			
		||||
          appName={name}
 | 
			
		||||
          app={app}
 | 
			
		||||
        />
 | 
			
		||||
        <ApplicationEnvVarsTable namespace={namespace} app={app} />
 | 
			
		||||
        <ApplicationVolumeConfigsTable namespace={namespace} app={app} />
 | 
			
		||||
        <ApplicationPersistentDataTable
 | 
			
		||||
          environmentId={environmentId}
 | 
			
		||||
          namespace={namespace}
 | 
			
		||||
          appName={name}
 | 
			
		||||
          app={app}
 | 
			
		||||
        />
 | 
			
		||||
      </WidgetBody>
 | 
			
		||||
    </Widget>
 | 
			
		||||
            <ApplicationServicesTable
 | 
			
		||||
              environmentId={environmentId}
 | 
			
		||||
              appServices={appServices}
 | 
			
		||||
            />
 | 
			
		||||
            <ApplicationIngressesTable
 | 
			
		||||
              appServices={appServices}
 | 
			
		||||
              environmentId={environmentId}
 | 
			
		||||
              namespace={namespace}
 | 
			
		||||
            />
 | 
			
		||||
            <ApplicationAutoScalingTable
 | 
			
		||||
              environmentId={environmentId}
 | 
			
		||||
              namespace={namespace}
 | 
			
		||||
              appName={name}
 | 
			
		||||
              app={app}
 | 
			
		||||
            />
 | 
			
		||||
            <ApplicationEnvVarsTable namespace={namespace} app={app} />
 | 
			
		||||
            <ApplicationVolumeConfigsTable namespace={namespace} app={app} />
 | 
			
		||||
            <ApplicationPersistentDataTable
 | 
			
		||||
              environmentId={environmentId}
 | 
			
		||||
              namespace={namespace}
 | 
			
		||||
              appName={name}
 | 
			
		||||
              app={app}
 | 
			
		||||
            />
 | 
			
		||||
          </WidgetBody>
 | 
			
		||||
        </Widget>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { User, Clock, Edit, ChevronRight, ChevronUp } from 'lucide-react';
 | 
			
		||||
import { User, Clock, Info } from 'lucide-react';
 | 
			
		||||
import moment from 'moment';
 | 
			
		||||
import { useEffect, useState } from 'react';
 | 
			
		||||
import { Pod } from 'kubernetes-types/core/v1';
 | 
			
		||||
| 
						 | 
				
			
			@ -10,7 +10,11 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications';
 | 
			
		|||
import { DetailsTable } from '@@/DetailsTable';
 | 
			
		||||
import { Badge } from '@@/Badge';
 | 
			
		||||
import { Link } from '@@/Link';
 | 
			
		||||
import { Button, LoadingButton } from '@@/buttons';
 | 
			
		||||
import { LoadingButton } from '@@/buttons';
 | 
			
		||||
import { WidgetBody, Widget } from '@@/Widget';
 | 
			
		||||
import { InlineLoader } from '@@/InlineLoader';
 | 
			
		||||
import { Icon } from '@@/Icon';
 | 
			
		||||
import { Note } from '@@/Note';
 | 
			
		||||
 | 
			
		||||
import { isSystemNamespace } from '../../namespaces/utils';
 | 
			
		||||
import {
 | 
			
		||||
| 
						 | 
				
			
			@ -44,13 +48,12 @@ export function ApplicationSummaryWidget() {
 | 
			
		|||
      endpointId: environmentId,
 | 
			
		||||
    },
 | 
			
		||||
  } = stateAndParams;
 | 
			
		||||
  const applicationQuery = useApplication(
 | 
			
		||||
  const { data: application, ...applicationQuery } = useApplication(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    resourceType
 | 
			
		||||
  );
 | 
			
		||||
  const application = applicationQuery.data;
 | 
			
		||||
  const systemNamespace = isSystemNamespace(namespace);
 | 
			
		||||
  const externalApplication = application && isExternalApplication(application);
 | 
			
		||||
  const applicationRequests = application && getResourceRequests(application);
 | 
			
		||||
| 
						 | 
				
			
			@ -59,7 +62,6 @@ export function ApplicationSummaryWidget() {
 | 
			
		|||
  const applicationNote =
 | 
			
		||||
    application?.metadata?.annotations?.[appNoteAnnotation];
 | 
			
		||||
 | 
			
		||||
  const [isNoteOpen, setIsNoteOpen] = useState(true);
 | 
			
		||||
  const [applicationNoteFormValues, setApplicationNoteFormValues] =
 | 
			
		||||
    useState('');
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -67,6 +69,9 @@ export function ApplicationSummaryWidget() {
 | 
			
		|||
    setApplicationNoteFormValues(applicationNote || '');
 | 
			
		||||
  }, [applicationNote]);
 | 
			
		||||
 | 
			
		||||
  const failedCreateCondition = application?.status?.conditions?.find(
 | 
			
		||||
    (condition) => condition.reason === 'FailedCreate'
 | 
			
		||||
  );
 | 
			
		||||
  const patchApplicationMutation = usePatchApplicationMutation(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
| 
						 | 
				
			
			@ -74,191 +79,197 @@ export function ApplicationSummaryWidget() {
 | 
			
		|||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="p-5">
 | 
			
		||||
      <DetailsTable>
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>Name</td>
 | 
			
		||||
          <td>
 | 
			
		||||
            <div
 | 
			
		||||
              className="flex items-center gap-x-2"
 | 
			
		||||
              data-cy="k8sAppDetail-appName"
 | 
			
		||||
            >
 | 
			
		||||
              {name}
 | 
			
		||||
              {externalApplication && !systemNamespace && (
 | 
			
		||||
                <Badge type="info">external</Badge>
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>Stack</td>
 | 
			
		||||
          <td data-cy="k8sAppDetail-stackName">
 | 
			
		||||
            {application?.metadata?.labels?.[appStackNameLabel] || '-'}
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>Namespace</td>
 | 
			
		||||
          <td>
 | 
			
		||||
            <div
 | 
			
		||||
              className="flex items-center gap-x-2"
 | 
			
		||||
              data-cy="k8sAppDetail-resourcePoolName"
 | 
			
		||||
            >
 | 
			
		||||
              <Link
 | 
			
		||||
                to="kubernetes.resourcePools.resourcePool"
 | 
			
		||||
                params={{ id: namespace }}
 | 
			
		||||
              >
 | 
			
		||||
                {namespace}
 | 
			
		||||
              </Link>
 | 
			
		||||
              {systemNamespace && <Badge type="info">system</Badge>}
 | 
			
		||||
            </div>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>Application type</td>
 | 
			
		||||
          <td data-cy="k8sAppDetail-appType">{application?.kind || '-'}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        {application?.kind && (
 | 
			
		||||
          <tr>
 | 
			
		||||
            <td>Status</td>
 | 
			
		||||
            {applicationIsKind<Pod>('Pod', application) && (
 | 
			
		||||
              <td data-cy="k8sAppDetail-appType">
 | 
			
		||||
                {application?.status?.phase}
 | 
			
		||||
              </td>
 | 
			
		||||
    <div className="row">
 | 
			
		||||
      <div className="col-sm-12">
 | 
			
		||||
        <Widget>
 | 
			
		||||
          <WidgetBody>
 | 
			
		||||
            {applicationQuery.isLoading && (
 | 
			
		||||
              <InlineLoader>Loading application...</InlineLoader>
 | 
			
		||||
            )}
 | 
			
		||||
            {!applicationIsKind<Pod>('Pod', application) && (
 | 
			
		||||
              <td data-cy="k8sAppDetail-appType">
 | 
			
		||||
                {appKindToDeploymentTypeMap[application.kind]}
 | 
			
		||||
                <code className="ml-1">
 | 
			
		||||
                  {getRunningPods(application)}
 | 
			
		||||
                </code> / <code>{getTotalPods(application)}</code>
 | 
			
		||||
              </td>
 | 
			
		||||
            )}
 | 
			
		||||
          </tr>
 | 
			
		||||
        )}
 | 
			
		||||
        {(!!applicationRequests?.cpu || !!applicationRequests?.memoryBytes) && (
 | 
			
		||||
          <tr>
 | 
			
		||||
            <td>
 | 
			
		||||
              Resource reservations
 | 
			
		||||
              {!applicationIsKind<Pod>('Pod', application) && (
 | 
			
		||||
                <div className="text-muted small">per instance</div>
 | 
			
		||||
              )}
 | 
			
		||||
            </td>
 | 
			
		||||
            <td>
 | 
			
		||||
              {!!applicationRequests?.cpu && (
 | 
			
		||||
                <div data-cy="k8sAppDetail-cpuReservation">
 | 
			
		||||
                  CPU {applicationRequests.cpu}
 | 
			
		||||
                </div>
 | 
			
		||||
              )}
 | 
			
		||||
              {!!applicationRequests?.memoryBytes && (
 | 
			
		||||
                <div data-cy="k8sAppDetail-memoryReservation">
 | 
			
		||||
                  Memory{' '}
 | 
			
		||||
                  {bytesToReadableFormat(applicationRequests.memoryBytes)}
 | 
			
		||||
                </div>
 | 
			
		||||
              )}
 | 
			
		||||
            </td>
 | 
			
		||||
          </tr>
 | 
			
		||||
        )}
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>Creation</td>
 | 
			
		||||
          <td>
 | 
			
		||||
            <div className="flex flex-wrap items-center gap-3">
 | 
			
		||||
              {applicationOwner && (
 | 
			
		||||
                <span
 | 
			
		||||
                  className="flex items-center gap-1"
 | 
			
		||||
                  data-cy="k8sAppDetail-owner"
 | 
			
		||||
                >
 | 
			
		||||
                  <User />
 | 
			
		||||
                  {applicationOwner}
 | 
			
		||||
                </span>
 | 
			
		||||
              )}
 | 
			
		||||
              <span
 | 
			
		||||
                className="flex items-center gap-1"
 | 
			
		||||
                data-cy="k8sAppDetail-creationDate"
 | 
			
		||||
              >
 | 
			
		||||
                <Clock />
 | 
			
		||||
                {moment(application?.metadata?.creationTimestamp).format(
 | 
			
		||||
                  'YYYY-MM-DD HH:mm:ss'
 | 
			
		||||
                )}
 | 
			
		||||
              </span>
 | 
			
		||||
              {(!externalApplication || systemNamespace) && (
 | 
			
		||||
                <span
 | 
			
		||||
                  className="flex items-center gap-1"
 | 
			
		||||
                  data-cy="k8sAppDetail-creationMethod"
 | 
			
		||||
                >
 | 
			
		||||
                  <Clock />
 | 
			
		||||
                  Deployed from {applicationDeployMethod}
 | 
			
		||||
                </span>
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td colSpan={2}>
 | 
			
		||||
            <form className="form-horizontal">
 | 
			
		||||
              <div className="form-group">
 | 
			
		||||
                <div className="col-sm-12 vertical-center">
 | 
			
		||||
                  <Button
 | 
			
		||||
                    size="small"
 | 
			
		||||
                    type="button"
 | 
			
		||||
                    color="none"
 | 
			
		||||
                    data-cy="k8sAppDetail-expandNoteButton"
 | 
			
		||||
                    onClick={() => setIsNoteOpen(!isNoteOpen)}
 | 
			
		||||
                    className="!m-0 !p-0"
 | 
			
		||||
            {application && (
 | 
			
		||||
              <>
 | 
			
		||||
                {failedCreateCondition && (
 | 
			
		||||
                  <div
 | 
			
		||||
                    className="vertical-center alert alert-danger mb-2"
 | 
			
		||||
                    data-cy="k8sAppDetail-failedCreateMessage"
 | 
			
		||||
                  >
 | 
			
		||||
                    {isNoteOpen ? <ChevronUp /> : <ChevronRight />} <Edit />{' '}
 | 
			
		||||
                    Note
 | 
			
		||||
                  </Button>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              {isNoteOpen && (
 | 
			
		||||
                <>
 | 
			
		||||
                  <div className="form-group">
 | 
			
		||||
                    <div className="col-sm-12">
 | 
			
		||||
                      <textarea
 | 
			
		||||
                        className="form-control resize-y"
 | 
			
		||||
                        name="application_note"
 | 
			
		||||
                        id="application_note"
 | 
			
		||||
                        value={applicationNoteFormValues}
 | 
			
		||||
                        onChange={(e) =>
 | 
			
		||||
                          setApplicationNoteFormValues(e.target.value)
 | 
			
		||||
                        }
 | 
			
		||||
                        rows={5}
 | 
			
		||||
                        placeholder="Enter a note about this application..."
 | 
			
		||||
                      />
 | 
			
		||||
                    <Icon icon={Info} className="mr-1" mode="danger" />
 | 
			
		||||
                    <div>
 | 
			
		||||
                      <div className="font-semibold">
 | 
			
		||||
                        Failed to create application
 | 
			
		||||
                      </div>
 | 
			
		||||
                      {failedCreateCondition.message}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
 | 
			
		||||
                  <Authorized authorizations="K8sApplicationDetailsW">
 | 
			
		||||
                    <div className="form-group">
 | 
			
		||||
                      <div className="col-sm-12">
 | 
			
		||||
                        <LoadingButton
 | 
			
		||||
                          color="primary"
 | 
			
		||||
                          size="small"
 | 
			
		||||
                          className="!ml-0"
 | 
			
		||||
                          type="button"
 | 
			
		||||
                          onClick={() => patchApplicationNote()}
 | 
			
		||||
                          disabled={
 | 
			
		||||
                            // disable if there is no change to the note, or it's updating
 | 
			
		||||
                            applicationNoteFormValues ===
 | 
			
		||||
                              (applicationNote || '') ||
 | 
			
		||||
                            patchApplicationMutation.isLoading
 | 
			
		||||
                          }
 | 
			
		||||
                          data-cy="k8sAppDetail-saveNoteButton"
 | 
			
		||||
                          isLoading={patchApplicationMutation.isLoading}
 | 
			
		||||
                          loadingText={applicationNote ? 'Updating' : 'Saving'}
 | 
			
		||||
                        >
 | 
			
		||||
                          {applicationNote ? 'Update' : 'Save'} note
 | 
			
		||||
                        </LoadingButton>
 | 
			
		||||
                )}
 | 
			
		||||
                <DetailsTable>
 | 
			
		||||
                  <tr>
 | 
			
		||||
                    <td>Name</td>
 | 
			
		||||
                    <td>
 | 
			
		||||
                      <div
 | 
			
		||||
                        className="flex items-center gap-x-2"
 | 
			
		||||
                        data-cy="k8sAppDetail-appName"
 | 
			
		||||
                      >
 | 
			
		||||
                        {name}
 | 
			
		||||
                        {externalApplication && !systemNamespace && (
 | 
			
		||||
                          <Badge type="info">external</Badge>
 | 
			
		||||
                        )}
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </Authorized>
 | 
			
		||||
                </>
 | 
			
		||||
              )}
 | 
			
		||||
            </form>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      </DetailsTable>
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
                  <tr>
 | 
			
		||||
                    <td>Stack</td>
 | 
			
		||||
                    <td data-cy="k8sAppDetail-stackName">
 | 
			
		||||
                      {application?.metadata?.labels?.[appStackNameLabel] ||
 | 
			
		||||
                        '-'}
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
                  <tr>
 | 
			
		||||
                    <td>Namespace</td>
 | 
			
		||||
                    <td>
 | 
			
		||||
                      <div
 | 
			
		||||
                        className="flex items-center gap-x-2"
 | 
			
		||||
                        data-cy="k8sAppDetail-resourcePoolName"
 | 
			
		||||
                      >
 | 
			
		||||
                        <Link
 | 
			
		||||
                          to="kubernetes.resourcePools.resourcePool"
 | 
			
		||||
                          params={{ id: namespace }}
 | 
			
		||||
                        >
 | 
			
		||||
                          {namespace}
 | 
			
		||||
                        </Link>
 | 
			
		||||
                        {systemNamespace && <Badge type="info">system</Badge>}
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
                  <tr>
 | 
			
		||||
                    <td>Application type</td>
 | 
			
		||||
                    <td data-cy="k8sAppDetail-appType">
 | 
			
		||||
                      {application?.kind || '-'}
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
                  {application?.kind && (
 | 
			
		||||
                    <tr>
 | 
			
		||||
                      <td>Status</td>
 | 
			
		||||
                      {applicationIsKind<Pod>('Pod', application) && (
 | 
			
		||||
                        <td data-cy="k8sAppDetail-appType">
 | 
			
		||||
                          {application?.status?.phase}
 | 
			
		||||
                        </td>
 | 
			
		||||
                      )}
 | 
			
		||||
                      {!applicationIsKind<Pod>('Pod', application) && (
 | 
			
		||||
                        <td data-cy="k8sAppDetail-appType">
 | 
			
		||||
                          {appKindToDeploymentTypeMap[application.kind]}
 | 
			
		||||
                          <code className="ml-1">
 | 
			
		||||
                            {getRunningPods(application)}
 | 
			
		||||
                          </code>{' '}
 | 
			
		||||
                          / <code>{getTotalPods(application)}</code>
 | 
			
		||||
                        </td>
 | 
			
		||||
                      )}
 | 
			
		||||
                    </tr>
 | 
			
		||||
                  )}
 | 
			
		||||
                  {(!!applicationRequests?.cpu ||
 | 
			
		||||
                    !!applicationRequests?.memoryBytes) && (
 | 
			
		||||
                    <tr>
 | 
			
		||||
                      <td>
 | 
			
		||||
                        Resource reservations
 | 
			
		||||
                        {!applicationIsKind<Pod>('Pod', application) && (
 | 
			
		||||
                          <div className="text-muted small">per instance</div>
 | 
			
		||||
                        )}
 | 
			
		||||
                      </td>
 | 
			
		||||
                      <td>
 | 
			
		||||
                        {!!applicationRequests?.cpu && (
 | 
			
		||||
                          <div data-cy="k8sAppDetail-cpuReservation">
 | 
			
		||||
                            CPU {applicationRequests.cpu}
 | 
			
		||||
                          </div>
 | 
			
		||||
                        )}
 | 
			
		||||
                        {!!applicationRequests?.memoryBytes && (
 | 
			
		||||
                          <div data-cy="k8sAppDetail-memoryReservation">
 | 
			
		||||
                            Memory{' '}
 | 
			
		||||
                            {bytesToReadableFormat(
 | 
			
		||||
                              applicationRequests.memoryBytes
 | 
			
		||||
                            )}
 | 
			
		||||
                          </div>
 | 
			
		||||
                        )}
 | 
			
		||||
                      </td>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                  )}
 | 
			
		||||
                  <tr>
 | 
			
		||||
                    <td>Creation</td>
 | 
			
		||||
                    <td>
 | 
			
		||||
                      <div className="flex flex-wrap items-center gap-3">
 | 
			
		||||
                        {applicationOwner && (
 | 
			
		||||
                          <span
 | 
			
		||||
                            className="flex items-center gap-1"
 | 
			
		||||
                            data-cy="k8sAppDetail-owner"
 | 
			
		||||
                          >
 | 
			
		||||
                            <User />
 | 
			
		||||
                            {applicationOwner}
 | 
			
		||||
                          </span>
 | 
			
		||||
                        )}
 | 
			
		||||
                        <span
 | 
			
		||||
                          className="flex items-center gap-1"
 | 
			
		||||
                          data-cy="k8sAppDetail-creationDate"
 | 
			
		||||
                        >
 | 
			
		||||
                          <Clock />
 | 
			
		||||
                          {moment(
 | 
			
		||||
                            application?.metadata?.creationTimestamp
 | 
			
		||||
                          ).format('YYYY-MM-DD HH:mm:ss')}
 | 
			
		||||
                        </span>
 | 
			
		||||
                        {(!externalApplication || systemNamespace) && (
 | 
			
		||||
                          <span
 | 
			
		||||
                            className="flex items-center gap-1"
 | 
			
		||||
                            data-cy="k8sAppDetail-creationMethod"
 | 
			
		||||
                          >
 | 
			
		||||
                            <Clock />
 | 
			
		||||
                            Deployed from {applicationDeployMethod}
 | 
			
		||||
                          </span>
 | 
			
		||||
                        )}
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
                  <tr>
 | 
			
		||||
                    <td colSpan={2}>
 | 
			
		||||
                      <form className="form-horizontal">
 | 
			
		||||
                        <Note
 | 
			
		||||
                          value={applicationNoteFormValues}
 | 
			
		||||
                          onChange={setApplicationNoteFormValues}
 | 
			
		||||
                          defaultIsOpen
 | 
			
		||||
                          isExpandable
 | 
			
		||||
                        />
 | 
			
		||||
                        <Authorized authorizations="K8sApplicationDetailsW">
 | 
			
		||||
                          <div className="form-group">
 | 
			
		||||
                            <div className="col-sm-12">
 | 
			
		||||
                              <LoadingButton
 | 
			
		||||
                                color="primary"
 | 
			
		||||
                                size="small"
 | 
			
		||||
                                className="!ml-0"
 | 
			
		||||
                                type="button"
 | 
			
		||||
                                onClick={() => patchApplicationNote()}
 | 
			
		||||
                                disabled={
 | 
			
		||||
                                  // disable if there is no change to the note, or it's updating
 | 
			
		||||
                                  applicationNoteFormValues ===
 | 
			
		||||
                                    (applicationNote || '') ||
 | 
			
		||||
                                  patchApplicationMutation.isLoading
 | 
			
		||||
                                }
 | 
			
		||||
                                data-cy="k8sAppDetail-saveNoteButton"
 | 
			
		||||
                                isLoading={patchApplicationMutation.isLoading}
 | 
			
		||||
                                loadingText={
 | 
			
		||||
                                  applicationNote ? 'Updating' : 'Saving'
 | 
			
		||||
                                }
 | 
			
		||||
                              >
 | 
			
		||||
                                {applicationNote ? 'Update' : 'Save'} note
 | 
			
		||||
                              </LoadingButton>
 | 
			
		||||
                            </div>
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </Authorized>
 | 
			
		||||
                      </form>
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
                </DetailsTable>
 | 
			
		||||
              </>
 | 
			
		||||
            )}
 | 
			
		||||
          </WidgetBody>
 | 
			
		||||
        </Widget>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,53 +2,60 @@ import { Minimize2 } from 'lucide-react';
 | 
			
		|||
 | 
			
		||||
import {
 | 
			
		||||
  BasicTableSettings,
 | 
			
		||||
  createPersistedStore,
 | 
			
		||||
  refreshableSettings,
 | 
			
		||||
  RefreshableTableSettings,
 | 
			
		||||
} from '@@/datatables/types';
 | 
			
		||||
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
 | 
			
		||||
import { useRepeater } from '@@/datatables/useRepeater';
 | 
			
		||||
import { TableSettingsMenu } from '@@/datatables';
 | 
			
		||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
 | 
			
		||||
import { useTableState } from '@@/datatables/useTableState';
 | 
			
		||||
import { TextTip } from '@@/Tip/TextTip';
 | 
			
		||||
 | 
			
		||||
import { Node } from '../types';
 | 
			
		||||
import { NodePlacementRowData } from '../types';
 | 
			
		||||
 | 
			
		||||
import { SubRow } from './PlacementsDatatableSubRow';
 | 
			
		||||
import { columns } from './columns';
 | 
			
		||||
 | 
			
		||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
 | 
			
		||||
 | 
			
		||||
function createStore(storageKey: string) {
 | 
			
		||||
  return createPersistedStore<TableSettings>(storageKey, 'node', (set) => ({
 | 
			
		||||
    ...refreshableSettings(set),
 | 
			
		||||
  }));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const storageKey = 'kubernetes.application.placements';
 | 
			
		||||
const settingsStore = createStore(storageKey);
 | 
			
		||||
type Props = {
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  dataset: NodePlacementRowData[];
 | 
			
		||||
  hasPlacementWarning: boolean;
 | 
			
		||||
  tableState: TableSettings & {
 | 
			
		||||
    setSearch: (value: string) => void;
 | 
			
		||||
    search: string;
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function PlacementsDatatable({
 | 
			
		||||
  isLoading,
 | 
			
		||||
  dataset,
 | 
			
		||||
  onRefresh,
 | 
			
		||||
}: {
 | 
			
		||||
  dataset: Node[];
 | 
			
		||||
  onRefresh: () => Promise<void>;
 | 
			
		||||
}) {
 | 
			
		||||
  const tableState = useTableState(settingsStore, storageKey);
 | 
			
		||||
 | 
			
		||||
  useRepeater(tableState.autoRefreshRate, onRefresh);
 | 
			
		||||
 | 
			
		||||
  hasPlacementWarning,
 | 
			
		||||
  tableState,
 | 
			
		||||
}: Props) {
 | 
			
		||||
  return (
 | 
			
		||||
    <ExpandableDatatable
 | 
			
		||||
      getRowCanExpand={(row) => !row.original.AcceptsApplication}
 | 
			
		||||
      isLoading={isLoading}
 | 
			
		||||
      getRowCanExpand={(row) => !row.original.acceptsApplication}
 | 
			
		||||
      title="Placement constraints/preferences"
 | 
			
		||||
      titleIcon={Minimize2}
 | 
			
		||||
      dataset={dataset}
 | 
			
		||||
      settingsManager={tableState}
 | 
			
		||||
      getRowId={(row) => row.name}
 | 
			
		||||
      columns={columns}
 | 
			
		||||
      disableSelect
 | 
			
		||||
      noWidget
 | 
			
		||||
      description={
 | 
			
		||||
        hasPlacementWarning ? (
 | 
			
		||||
          <TextTip>
 | 
			
		||||
            Based on the placement rules, the application pod can't be
 | 
			
		||||
            scheduled on any nodes.
 | 
			
		||||
          </TextTip>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <TextTip color="blue">
 | 
			
		||||
            The placement table helps you understand whether or not this
 | 
			
		||||
            application can be deployed on a specific node.
 | 
			
		||||
          </TextTip>
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
      renderTableSettings={() => (
 | 
			
		||||
        <TableSettingsMenu>
 | 
			
		||||
          <TableSettingsMenuAutoRefresh
 | 
			
		||||
| 
						 | 
				
			
			@ -1,13 +1,14 @@
 | 
			
		|||
import clsx from 'clsx';
 | 
			
		||||
import { Fragment } from 'react';
 | 
			
		||||
import { Taint } from 'kubernetes-types/core/v1';
 | 
			
		||||
 | 
			
		||||
import { nodeAffinityValues } from '@/kubernetes/filters/application';
 | 
			
		||||
import { useAuthorizations } from '@/react/hooks/useUser';
 | 
			
		||||
 | 
			
		||||
import { Affinity, Label, Node, Taint } from '../types';
 | 
			
		||||
import { Affinity, Label, NodePlacementRowData } from '../types';
 | 
			
		||||
 | 
			
		||||
interface SubRowProps {
 | 
			
		||||
  node: Node;
 | 
			
		||||
  node: NodePlacementRowData;
 | 
			
		||||
  cellCount: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -20,11 +21,11 @@ export function SubRow({ node, cellCount }: SubRowProps) {
 | 
			
		|||
 | 
			
		||||
  if (!authorized) {
 | 
			
		||||
    <>
 | 
			
		||||
      {isDefined(node.UnmetTaints) && (
 | 
			
		||||
      {isDefined(node.unmetTaints) && (
 | 
			
		||||
        <tr
 | 
			
		||||
          className={clsx({
 | 
			
		||||
            'datatable-highlighted': node.Highlighted,
 | 
			
		||||
            'datatable-unhighlighted': !node.Highlighted,
 | 
			
		||||
            'datatable-highlighted': node.highlighted,
 | 
			
		||||
            'datatable-unhighlighted': !node.highlighted,
 | 
			
		||||
          })}
 | 
			
		||||
        >
 | 
			
		||||
          <td colSpan={cellCount}>
 | 
			
		||||
| 
						 | 
				
			
			@ -33,12 +34,12 @@ export function SubRow({ node, cellCount }: SubRowProps) {
 | 
			
		|||
        </tr>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(isDefined(node.UnmatchedNodeSelectorLabels) ||
 | 
			
		||||
        isDefined(node.UnmatchedNodeAffinities)) && (
 | 
			
		||||
      {(isDefined(node.unmatchedNodeSelectorLabels) ||
 | 
			
		||||
        isDefined(node.unmatchedNodeAffinities)) && (
 | 
			
		||||
        <tr
 | 
			
		||||
          className={clsx({
 | 
			
		||||
            'datatable-highlighted': node.Highlighted,
 | 
			
		||||
            'datatable-unhighlighted': !node.Highlighted,
 | 
			
		||||
            'datatable-highlighted': node.highlighted,
 | 
			
		||||
            'datatable-unhighlighted': !node.highlighted,
 | 
			
		||||
          })}
 | 
			
		||||
        >
 | 
			
		||||
          <td colSpan={cellCount}>
 | 
			
		||||
| 
						 | 
				
			
			@ -51,25 +52,25 @@ export function SubRow({ node, cellCount }: SubRowProps) {
 | 
			
		|||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {isDefined(node.UnmetTaints) && (
 | 
			
		||||
      {isDefined(node.unmetTaints) && (
 | 
			
		||||
        <UnmetTaintsInfo
 | 
			
		||||
          taints={node.UnmetTaints}
 | 
			
		||||
          taints={node.unmetTaints}
 | 
			
		||||
          cellCount={cellCount}
 | 
			
		||||
          isHighlighted={node.Highlighted}
 | 
			
		||||
          isHighlighted={node.highlighted}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {isDefined(node.UnmatchedNodeSelectorLabels) && (
 | 
			
		||||
      {isDefined(node.unmatchedNodeSelectorLabels) && (
 | 
			
		||||
        <UnmatchedLabelsInfo
 | 
			
		||||
          labels={node.UnmatchedNodeSelectorLabels}
 | 
			
		||||
          labels={node.unmatchedNodeSelectorLabels}
 | 
			
		||||
          cellCount={cellCount}
 | 
			
		||||
          isHighlighted={node.Highlighted}
 | 
			
		||||
          isHighlighted={node.highlighted}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {isDefined(node.UnmatchedNodeAffinities) && (
 | 
			
		||||
      {isDefined(node.unmatchedNodeAffinities) && (
 | 
			
		||||
        <UnmatchedAffinitiesInfo
 | 
			
		||||
          affinities={node.UnmatchedNodeAffinities}
 | 
			
		||||
          affinities={node.unmatchedNodeAffinities}
 | 
			
		||||
          cellCount={cellCount}
 | 
			
		||||
          isHighlighted={node.Highlighted}
 | 
			
		||||
          isHighlighted={node.highlighted}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
| 
						 | 
				
			
			@ -97,13 +98,13 @@ function UnmetTaintsInfo({
 | 
			
		|||
            'datatable-highlighted': isHighlighted,
 | 
			
		||||
            'datatable-unhighlighted': !isHighlighted,
 | 
			
		||||
          })}
 | 
			
		||||
          key={taint.Key}
 | 
			
		||||
          key={taint.key}
 | 
			
		||||
        >
 | 
			
		||||
          <td colSpan={cellCount}>
 | 
			
		||||
            This application is missing a toleration for the taint
 | 
			
		||||
            <code className="space-left">
 | 
			
		||||
              {taint.Key}
 | 
			
		||||
              {taint.Value ? `=${taint.Value}` : ''}:{taint.Effect}
 | 
			
		||||
              {taint.key}
 | 
			
		||||
              {taint.value ? `=${taint.value}` : ''}:{taint.effect}
 | 
			
		||||
            </code>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import { createColumnHelper } from '@tanstack/react-table';
 | 
			
		||||
 | 
			
		||||
import { NodePlacementRowData } from '../../types';
 | 
			
		||||
 | 
			
		||||
export const columnHelper = createColumnHelper<NodePlacementRowData>();
 | 
			
		||||
| 
						 | 
				
			
			@ -1,14 +1,14 @@
 | 
			
		|||
import { buildExpandColumn } from '@@/datatables/expand-column';
 | 
			
		||||
 | 
			
		||||
import { Node } from '../../types';
 | 
			
		||||
import { NodePlacementRowData } from '../../types';
 | 
			
		||||
 | 
			
		||||
import { columnHelper } from './helper';
 | 
			
		||||
import { status } from './status';
 | 
			
		||||
 | 
			
		||||
export const columns = [
 | 
			
		||||
  buildExpandColumn<Node>(),
 | 
			
		||||
  buildExpandColumn<NodePlacementRowData>(),
 | 
			
		||||
  status,
 | 
			
		||||
  columnHelper.accessor('Name', {
 | 
			
		||||
  columnHelper.accessor('name', {
 | 
			
		||||
    header: 'Node',
 | 
			
		||||
    id: 'node',
 | 
			
		||||
  }),
 | 
			
		||||
| 
						 | 
				
			
			@ -4,7 +4,7 @@ import { Icon } from '@@/Icon';
 | 
			
		|||
 | 
			
		||||
import { columnHelper } from './helper';
 | 
			
		||||
 | 
			
		||||
export const status = columnHelper.accessor('AcceptsApplication', {
 | 
			
		||||
export const status = columnHelper.accessor('acceptsApplication', {
 | 
			
		||||
  header: '',
 | 
			
		||||
  id: 'status',
 | 
			
		||||
  enableSorting: false,
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
export { PlacementsDatatable } from './PlacementsDatatable';
 | 
			
		||||
export {
 | 
			
		||||
  usePlacementTableState,
 | 
			
		||||
  usePlacementTableData,
 | 
			
		||||
} from './usePlacementTableData';
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,249 @@
 | 
			
		|||
import { useCurrentStateAndParams } from '@uirouter/react';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
import { Pod, Taint, Node } from 'kubernetes-types/core/v1';
 | 
			
		||||
import _ from 'lodash';
 | 
			
		||||
import * as JsonPatch from 'fast-json-patch';
 | 
			
		||||
 | 
			
		||||
import { useNodesQuery } from '@/react/kubernetes/cluster/HomeView/nodes.service';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  BasicTableSettings,
 | 
			
		||||
  RefreshableTableSettings,
 | 
			
		||||
  createPersistedStore,
 | 
			
		||||
  refreshableSettings,
 | 
			
		||||
} from '@@/datatables/types';
 | 
			
		||||
import { useTableState } from '@@/datatables/useTableState';
 | 
			
		||||
 | 
			
		||||
import { useApplication, useApplicationPods } from '../../application.queries';
 | 
			
		||||
import { NodePlacementRowData } from '../types';
 | 
			
		||||
 | 
			
		||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
 | 
			
		||||
 | 
			
		||||
function createStore(storageKey: string) {
 | 
			
		||||
  return createPersistedStore<TableSettings>(storageKey, 'node', (set) => ({
 | 
			
		||||
    ...refreshableSettings(set),
 | 
			
		||||
  }));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const storageKey = 'kubernetes.application.placements';
 | 
			
		||||
const placementsSettingsStore = createStore(storageKey);
 | 
			
		||||
 | 
			
		||||
export function usePlacementTableState() {
 | 
			
		||||
  return useTableState(placementsSettingsStore, storageKey);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function usePlacementTableData() {
 | 
			
		||||
  const placementsTableState = usePlacementTableState();
 | 
			
		||||
  const autoRefreshRate = placementsTableState.autoRefreshRate * 1000; // ms to seconds
 | 
			
		||||
 | 
			
		||||
  const stateAndParams = useCurrentStateAndParams();
 | 
			
		||||
  const {
 | 
			
		||||
    params: {
 | 
			
		||||
      namespace,
 | 
			
		||||
      name,
 | 
			
		||||
      'resource-type': resourceType,
 | 
			
		||||
      endpointId: environmentId,
 | 
			
		||||
    },
 | 
			
		||||
  } = stateAndParams;
 | 
			
		||||
  const { data: application, ...applicationQuery } = useApplication(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    resourceType,
 | 
			
		||||
    { autoRefreshRate }
 | 
			
		||||
  );
 | 
			
		||||
  const { data: pods, ...podsQuery } = useApplicationPods(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    application,
 | 
			
		||||
    { autoRefreshRate }
 | 
			
		||||
  );
 | 
			
		||||
  const { data: nodes, ...nodesQuery } = useNodesQuery(environmentId, {
 | 
			
		||||
    autoRefreshRate,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const placementsData = useMemo(
 | 
			
		||||
    () => (nodes && pods ? computePlacements(nodes, pods) : []),
 | 
			
		||||
    [nodes, pods]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const isPlacementsTableLoading =
 | 
			
		||||
    applicationQuery.isLoading || nodesQuery.isLoading || podsQuery.isLoading;
 | 
			
		||||
 | 
			
		||||
  const hasPlacementWarning = useMemo(() => {
 | 
			
		||||
    const notAllowedOnEveryNode = placementsData.every(
 | 
			
		||||
      (nodePlacement) => !nodePlacement.acceptsApplication
 | 
			
		||||
    );
 | 
			
		||||
    return !isPlacementsTableLoading && notAllowedOnEveryNode;
 | 
			
		||||
  }, [isPlacementsTableLoading, placementsData]);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    placementsData,
 | 
			
		||||
    isPlacementsTableLoading,
 | 
			
		||||
    hasPlacementWarning,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function computePlacements(
 | 
			
		||||
  nodes: Node[],
 | 
			
		||||
  pods: Pod[]
 | 
			
		||||
): NodePlacementRowData[] {
 | 
			
		||||
  const pod = pods?.[0];
 | 
			
		||||
  if (!pod) {
 | 
			
		||||
    return [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const placementDataFromTolerations: NodePlacementRowData[] =
 | 
			
		||||
    computeTolerations(nodes, pod);
 | 
			
		||||
  const placementDataFromAffinities: NodePlacementRowData[] = computeAffinities(
 | 
			
		||||
    nodes,
 | 
			
		||||
    placementDataFromTolerations,
 | 
			
		||||
    pod
 | 
			
		||||
  );
 | 
			
		||||
  return placementDataFromAffinities;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function computeTolerations(nodes: Node[], pod: Pod): NodePlacementRowData[] {
 | 
			
		||||
  const tolerations = pod.spec?.tolerations || [];
 | 
			
		||||
  const nodePlacements: NodePlacementRowData[] = nodes.map((node) => {
 | 
			
		||||
    let acceptsApplication = true;
 | 
			
		||||
    const unmetTaints: Taint[] = [];
 | 
			
		||||
    const taints = node.spec?.taints || [];
 | 
			
		||||
    taints.forEach((taint) => {
 | 
			
		||||
      const matchKeyMatchValueMatchEffect = _.find(tolerations, {
 | 
			
		||||
        key: taint.key,
 | 
			
		||||
        operator: 'Equal',
 | 
			
		||||
        value: taint.value,
 | 
			
		||||
        effect: taint.effect,
 | 
			
		||||
      });
 | 
			
		||||
      const matchKeyAnyValueMatchEffect = _.find(tolerations, {
 | 
			
		||||
        key: taint.key,
 | 
			
		||||
        operator: 'Exists',
 | 
			
		||||
        effect: taint.effect,
 | 
			
		||||
      });
 | 
			
		||||
      const matchKeyMatchValueAnyEffect = _.find(tolerations, {
 | 
			
		||||
        key: taint.key,
 | 
			
		||||
        operator: 'Equal',
 | 
			
		||||
        value: taint.value,
 | 
			
		||||
        effect: '',
 | 
			
		||||
      });
 | 
			
		||||
      const matchKeyAnyValueAnyEffect = _.find(tolerations, {
 | 
			
		||||
        key: taint.key,
 | 
			
		||||
        operator: 'Exists',
 | 
			
		||||
        effect: '',
 | 
			
		||||
      });
 | 
			
		||||
      const anyKeyAnyValueAnyEffect = _.find(tolerations, {
 | 
			
		||||
        key: '',
 | 
			
		||||
        operator: 'Exists',
 | 
			
		||||
        effect: '',
 | 
			
		||||
      });
 | 
			
		||||
      if (
 | 
			
		||||
        !matchKeyMatchValueMatchEffect &&
 | 
			
		||||
        !matchKeyAnyValueMatchEffect &&
 | 
			
		||||
        !matchKeyMatchValueAnyEffect &&
 | 
			
		||||
        !matchKeyAnyValueAnyEffect &&
 | 
			
		||||
        !anyKeyAnyValueAnyEffect
 | 
			
		||||
      ) {
 | 
			
		||||
        acceptsApplication = false;
 | 
			
		||||
        unmetTaints?.push(taint);
 | 
			
		||||
      } else {
 | 
			
		||||
        acceptsApplication = true;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return {
 | 
			
		||||
      name: node.metadata?.name || '',
 | 
			
		||||
      acceptsApplication,
 | 
			
		||||
      unmetTaints,
 | 
			
		||||
      highlighted: false,
 | 
			
		||||
    };
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return nodePlacements;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Node requirement depending on the operator value
 | 
			
		||||
// https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity
 | 
			
		||||
function computeAffinities(
 | 
			
		||||
  nodes: Node[],
 | 
			
		||||
  nodePlacements: NodePlacementRowData[],
 | 
			
		||||
  pod: Pod
 | 
			
		||||
): NodePlacementRowData[] {
 | 
			
		||||
  const nodePlacementsFromAffinities: NodePlacementRowData[] = nodes.map(
 | 
			
		||||
    (node, nodeIndex) => {
 | 
			
		||||
      let { acceptsApplication } = nodePlacements[nodeIndex];
 | 
			
		||||
 | 
			
		||||
      if (pod.spec?.nodeSelector) {
 | 
			
		||||
        const patch = JsonPatch.compare(
 | 
			
		||||
          node.metadata?.labels || {},
 | 
			
		||||
          pod.spec.nodeSelector
 | 
			
		||||
        );
 | 
			
		||||
        _.remove(patch, { op: 'remove' });
 | 
			
		||||
        const unmatchedNodeSelectorLabels = patch.map((operation) => ({
 | 
			
		||||
          key: _.trimStart(operation.path, '/'),
 | 
			
		||||
          value: operation.op,
 | 
			
		||||
        }));
 | 
			
		||||
        if (unmatchedNodeSelectorLabels.length) {
 | 
			
		||||
          acceptsApplication = false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const basicNodeAffinity =
 | 
			
		||||
        pod.spec?.affinity?.nodeAffinity
 | 
			
		||||
          ?.requiredDuringSchedulingIgnoredDuringExecution;
 | 
			
		||||
      if (basicNodeAffinity) {
 | 
			
		||||
        const unmatchedTerms = basicNodeAffinity.nodeSelectorTerms.map(
 | 
			
		||||
          (selectorTerm) => {
 | 
			
		||||
            const unmatchedExpressions = selectorTerm.matchExpressions?.flatMap(
 | 
			
		||||
              (matchExpression) => {
 | 
			
		||||
                const exists = {}.hasOwnProperty.call(
 | 
			
		||||
                  node.metadata?.labels,
 | 
			
		||||
                  matchExpression.key
 | 
			
		||||
                );
 | 
			
		||||
                const isIn =
 | 
			
		||||
                  exists &&
 | 
			
		||||
                  _.includes(
 | 
			
		||||
                    matchExpression.values,
 | 
			
		||||
                    node.metadata?.labels?.[matchExpression.key]
 | 
			
		||||
                  );
 | 
			
		||||
                if (
 | 
			
		||||
                  (matchExpression.operator === 'Exists' && exists) ||
 | 
			
		||||
                  (matchExpression.operator === 'DoesNotExist' && !exists) ||
 | 
			
		||||
                  (matchExpression.operator === 'In' && isIn) ||
 | 
			
		||||
                  (matchExpression.operator === 'NotIn' && !isIn) ||
 | 
			
		||||
                  (matchExpression.operator === 'Gt' &&
 | 
			
		||||
                    exists &&
 | 
			
		||||
                    parseInt(
 | 
			
		||||
                      node.metadata?.labels?.[matchExpression.key] || '',
 | 
			
		||||
                      10
 | 
			
		||||
                    ) > parseInt(matchExpression.values?.[0] || '', 10)) ||
 | 
			
		||||
                  (matchExpression.operator === 'Lt' &&
 | 
			
		||||
                    exists &&
 | 
			
		||||
                    parseInt(
 | 
			
		||||
                      node.metadata?.labels?.[matchExpression.key] || '',
 | 
			
		||||
                      10
 | 
			
		||||
                    ) < parseInt(matchExpression.values?.[0] || '', 10))
 | 
			
		||||
                ) {
 | 
			
		||||
                  return [];
 | 
			
		||||
                }
 | 
			
		||||
                return [true];
 | 
			
		||||
              }
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            return unmatchedExpressions;
 | 
			
		||||
          }
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        _.remove(unmatchedTerms, (i) => i?.length === 0);
 | 
			
		||||
        if (unmatchedTerms.length) {
 | 
			
		||||
          acceptsApplication = false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      return {
 | 
			
		||||
        ...nodePlacements[nodeIndex],
 | 
			
		||||
        acceptsApplication,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
  return nodePlacementsFromAffinities;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,10 +1,6 @@
 | 
			
		|||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '@/kubernetes/pod/models';
 | 
			
		||||
import { Taint } from 'kubernetes-types/core/v1';
 | 
			
		||||
 | 
			
		||||
export interface Taint {
 | 
			
		||||
  Key: string;
 | 
			
		||||
  Value?: string;
 | 
			
		||||
  Effect: string;
 | 
			
		||||
}
 | 
			
		||||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '@/kubernetes/pod/models';
 | 
			
		||||
 | 
			
		||||
export interface Label {
 | 
			
		||||
  key: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -19,11 +15,11 @@ interface AffinityTerm {
 | 
			
		|||
 | 
			
		||||
export type Affinity = Array<AffinityTerm>;
 | 
			
		||||
 | 
			
		||||
export type Node = {
 | 
			
		||||
  Name: string;
 | 
			
		||||
  AcceptsApplication: boolean;
 | 
			
		||||
  UnmetTaints?: Array<Taint>;
 | 
			
		||||
  UnmatchedNodeSelectorLabels?: Array<Label>;
 | 
			
		||||
  Highlighted: boolean;
 | 
			
		||||
  UnmatchedNodeAffinities?: Array<Affinity>;
 | 
			
		||||
export type NodePlacementRowData = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  acceptsApplication: boolean;
 | 
			
		||||
  unmetTaints?: Array<Taint>;
 | 
			
		||||
  unmatchedNodeSelectorLabels?: Array<Label>;
 | 
			
		||||
  highlighted: boolean;
 | 
			
		||||
  unmatchedNodeAffinities?: Array<Affinity>;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,88 @@
 | 
			
		|||
import { useCurrentStateAndParams } from '@uirouter/react';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
 | 
			
		||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
 | 
			
		||||
 | 
			
		||||
import { useTableState } from '@@/datatables/useTableState';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  useApplication,
 | 
			
		||||
  useApplicationPods,
 | 
			
		||||
  useApplicationServices,
 | 
			
		||||
} from '../application.queries';
 | 
			
		||||
 | 
			
		||||
import { useNamespaceEventsQuery } from './useNamespaceEventsQuery';
 | 
			
		||||
 | 
			
		||||
const storageKey = 'k8sAppEventsDatatable';
 | 
			
		||||
const settingsStore = createStore(storageKey, { id: 'Date', desc: true });
 | 
			
		||||
 | 
			
		||||
export function useApplicationEventsTableState() {
 | 
			
		||||
  return useTableState(settingsStore, storageKey);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useApplicationEventsTableData() {
 | 
			
		||||
  const appEventsTableState = useTableState(settingsStore, storageKey);
 | 
			
		||||
  const {
 | 
			
		||||
    params: {
 | 
			
		||||
      namespace,
 | 
			
		||||
      name,
 | 
			
		||||
      'resource-type': resourceType,
 | 
			
		||||
      endpointId: environmentId,
 | 
			
		||||
    },
 | 
			
		||||
  } = useCurrentStateAndParams();
 | 
			
		||||
 | 
			
		||||
  const { data: application, ...applicationQuery } = useApplication(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    resourceType
 | 
			
		||||
  );
 | 
			
		||||
  const { data: services, ...servicesQuery } = useApplicationServices(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    application
 | 
			
		||||
  );
 | 
			
		||||
  const { data: pods, ...podsQuery } = useApplicationPods(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    application
 | 
			
		||||
  );
 | 
			
		||||
  const { data: events, ...eventsQuery } = useNamespaceEventsQuery(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    namespace,
 | 
			
		||||
    {
 | 
			
		||||
      autoRefreshRate: appEventsTableState.autoRefreshRate * 1000,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // related events are events that have the application id, or the id of a service or pod from the application
 | 
			
		||||
  const appEventsData = useMemo(() => {
 | 
			
		||||
    const serviceIds = services?.map((service) => service?.metadata?.uid);
 | 
			
		||||
    const podIds = pods?.map((pod) => pod?.metadata?.uid);
 | 
			
		||||
    return (
 | 
			
		||||
      events?.filter(
 | 
			
		||||
        (event) =>
 | 
			
		||||
          event.involvedObject.uid === application?.metadata?.uid ||
 | 
			
		||||
          serviceIds?.includes(event.involvedObject.uid) ||
 | 
			
		||||
          podIds?.includes(event.involvedObject.uid)
 | 
			
		||||
      ) || []
 | 
			
		||||
    );
 | 
			
		||||
  }, [application?.metadata?.uid, events, pods, services]);
 | 
			
		||||
 | 
			
		||||
  const appEventWarningCount = useMemo(
 | 
			
		||||
    () => appEventsData.filter((event) => event.type === 'Warning').length,
 | 
			
		||||
    [appEventsData]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    appEventsData,
 | 
			
		||||
    appEventWarningCount,
 | 
			
		||||
    isAppEventsTableLoading:
 | 
			
		||||
      applicationQuery.isLoading ||
 | 
			
		||||
      servicesQuery.isLoading ||
 | 
			
		||||
      podsQuery.isLoading ||
 | 
			
		||||
      eventsQuery.isLoading,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +0,0 @@
 | 
			
		|||
import { createColumnHelper } from '@tanstack/react-table';
 | 
			
		||||
 | 
			
		||||
import { Node } from '../../types';
 | 
			
		||||
 | 
			
		||||
export const columnHelper = createColumnHelper<Node>();
 | 
			
		||||
| 
						 | 
				
			
			@ -1 +0,0 @@
 | 
			
		|||
export { PlacementsDatatable } from './PlacementsDatatable';
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { useMutation, useQuery } from 'react-query';
 | 
			
		||||
import { UseQueryResult, useMutation, useQuery } from 'react-query';
 | 
			
		||||
import { Pod } from 'kubernetes-types/core/v1';
 | 
			
		||||
 | 
			
		||||
import { queryClient, withError } from '@/react-tools/react-query';
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +27,8 @@ const queryKeys = {
 | 
			
		|||
  application: (
 | 
			
		||||
    environmentId: EnvironmentId,
 | 
			
		||||
    namespace: string,
 | 
			
		||||
    name: string
 | 
			
		||||
    name: string,
 | 
			
		||||
    yaml?: boolean
 | 
			
		||||
  ) => [
 | 
			
		||||
    'environments',
 | 
			
		||||
    environmentId,
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +36,7 @@ const queryKeys = {
 | 
			
		|||
    'applications',
 | 
			
		||||
    namespace,
 | 
			
		||||
    name,
 | 
			
		||||
    yaml,
 | 
			
		||||
  ],
 | 
			
		||||
  applicationRevisions: (
 | 
			
		||||
    environmentId: EnvironmentId,
 | 
			
		||||
| 
						 | 
				
			
			@ -120,18 +122,23 @@ export function useApplicationsForCluster(
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// useQuery to get an application by environmentId, namespace and name
 | 
			
		||||
export function useApplication(
 | 
			
		||||
// when yaml is set to true, the expected return type is a string
 | 
			
		||||
export function useApplication<T extends Application | string = Application>(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  name: string,
 | 
			
		||||
  appKind?: AppKind
 | 
			
		||||
) {
 | 
			
		||||
  appKind?: AppKind,
 | 
			
		||||
  options?: { autoRefreshRate?: number; yaml?: boolean }
 | 
			
		||||
): UseQueryResult<T> {
 | 
			
		||||
  return useQuery(
 | 
			
		||||
    queryKeys.application(environmentId, namespace, name),
 | 
			
		||||
    () => getApplication(environmentId, namespace, name, appKind),
 | 
			
		||||
    queryKeys.application(environmentId, namespace, name, options?.yaml),
 | 
			
		||||
    () =>
 | 
			
		||||
      getApplication<T>(environmentId, namespace, name, appKind, options?.yaml),
 | 
			
		||||
    {
 | 
			
		||||
      ...withError('Unable to retrieve application'),
 | 
			
		||||
      refetchInterval() {
 | 
			
		||||
        return options?.autoRefreshRate ?? false;
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -212,7 +219,7 @@ export function useApplicationServices(
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
// useApplicationHorizontalPodAutoscalers returns a query for horizontal pod autoscalers that are related to the application
 | 
			
		||||
export function useApplicationHorizontalPodAutoscalers(
 | 
			
		||||
export function useApplicationHorizontalPodAutoscaler(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  appName: string,
 | 
			
		||||
| 
						 | 
				
			
			@ -231,7 +238,7 @@ export function useApplicationHorizontalPodAutoscalers(
 | 
			
		|||
 | 
			
		||||
      const horizontalPodAutoscalers =
 | 
			
		||||
        await getNamespaceHorizontalPodAutoscalers(environmentId, namespace);
 | 
			
		||||
      const filteredHorizontalPodAutoscalers =
 | 
			
		||||
      const matchingHorizontalPodAutoscaler =
 | 
			
		||||
        horizontalPodAutoscalers.find((horizontalPodAutoscaler) => {
 | 
			
		||||
          const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef;
 | 
			
		||||
          if (scaleTargetRef) {
 | 
			
		||||
| 
						 | 
				
			
			@ -245,11 +252,11 @@ export function useApplicationHorizontalPodAutoscalers(
 | 
			
		|||
          }
 | 
			
		||||
          return false;
 | 
			
		||||
        }) || null;
 | 
			
		||||
      return filteredHorizontalPodAutoscalers;
 | 
			
		||||
      return matchingHorizontalPodAutoscaler;
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      ...withError(
 | 
			
		||||
        `Unable to get horizontal pod autoscalers${
 | 
			
		||||
        `Unable to get horizontal pod autoscaler${
 | 
			
		||||
          app ? ` for ${app.metadata?.name}` : ''
 | 
			
		||||
        }`
 | 
			
		||||
      ),
 | 
			
		||||
| 
						 | 
				
			
			@ -263,7 +270,8 @@ export function useApplicationPods(
 | 
			
		|||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  appName: string,
 | 
			
		||||
  app?: Application
 | 
			
		||||
  app?: Application,
 | 
			
		||||
  options?: { autoRefreshRate?: number }
 | 
			
		||||
) {
 | 
			
		||||
  return useQuery(
 | 
			
		||||
    queryKeys.applicationPods(environmentId, namespace, appName),
 | 
			
		||||
| 
						 | 
				
			
			@ -287,6 +295,9 @@ export function useApplicationPods(
 | 
			
		|||
    {
 | 
			
		||||
      ...withError(`Unable to get pods for ${appName}`),
 | 
			
		||||
      enabled: !!app,
 | 
			
		||||
      refetchInterval() {
 | 
			
		||||
        return options?.autoRefreshRate ?? false;
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -83,11 +83,14 @@ async function getApplicationsForNamespace(
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
// if not known, get the type of an application (Deployment, DaemonSet, StatefulSet or naked pod) by name
 | 
			
		||||
export async function getApplication(
 | 
			
		||||
export async function getApplication<
 | 
			
		||||
  T extends Application | string = Application
 | 
			
		||||
>(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  name: string,
 | 
			
		||||
  appKind?: AppKind
 | 
			
		||||
  appKind?: AppKind,
 | 
			
		||||
  yaml?: boolean
 | 
			
		||||
) {
 | 
			
		||||
  try {
 | 
			
		||||
    // if resourceType is known, get the application by type and name
 | 
			
		||||
| 
						 | 
				
			
			@ -96,14 +99,15 @@ export async function getApplication(
 | 
			
		|||
        case 'Deployment':
 | 
			
		||||
        case 'DaemonSet':
 | 
			
		||||
        case 'StatefulSet':
 | 
			
		||||
          return await getApplicationByKind(
 | 
			
		||||
          return await getApplicationByKind<T>(
 | 
			
		||||
            environmentId,
 | 
			
		||||
            namespace,
 | 
			
		||||
            appKind,
 | 
			
		||||
            name
 | 
			
		||||
            name,
 | 
			
		||||
            yaml
 | 
			
		||||
          );
 | 
			
		||||
        case 'Pod':
 | 
			
		||||
          return await getPod(environmentId, namespace, name);
 | 
			
		||||
          return await getPod(environmentId, namespace, name, yaml);
 | 
			
		||||
        default:
 | 
			
		||||
          throw new Error('Unknown resource type');
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -115,21 +119,24 @@ export async function getApplication(
 | 
			
		|||
        environmentId,
 | 
			
		||||
        namespace,
 | 
			
		||||
        'Deployment',
 | 
			
		||||
        name
 | 
			
		||||
        name,
 | 
			
		||||
        yaml
 | 
			
		||||
      ),
 | 
			
		||||
      getApplicationByKind<DaemonSet>(
 | 
			
		||||
        environmentId,
 | 
			
		||||
        namespace,
 | 
			
		||||
        'DaemonSet',
 | 
			
		||||
        name
 | 
			
		||||
        name,
 | 
			
		||||
        yaml
 | 
			
		||||
      ),
 | 
			
		||||
      getApplicationByKind<StatefulSet>(
 | 
			
		||||
        environmentId,
 | 
			
		||||
        namespace,
 | 
			
		||||
        'StatefulSet',
 | 
			
		||||
        name
 | 
			
		||||
        name,
 | 
			
		||||
        yaml
 | 
			
		||||
      ),
 | 
			
		||||
      getPod(environmentId, namespace, name),
 | 
			
		||||
      getPod(environmentId, namespace, name, yaml),
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    if (isFulfilled(deployment)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -225,15 +232,21 @@ async function patchApplicationByKind<T extends Application>(
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getApplicationByKind<T extends Application>(
 | 
			
		||||
async function getApplicationByKind<
 | 
			
		||||
  T extends Application | string = Application
 | 
			
		||||
>(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
 | 
			
		||||
  name: string
 | 
			
		||||
  name: string,
 | 
			
		||||
  yaml?: boolean
 | 
			
		||||
) {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios.get<T>(
 | 
			
		||||
      buildUrl(environmentId, namespace, `${appKind}s`, name)
 | 
			
		||||
      buildUrl(environmentId, namespace, `${appKind}s`, name),
 | 
			
		||||
      {
 | 
			
		||||
        headers: { Accept: yaml ? 'application/yaml' : 'application/json' },
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,14 +1,88 @@
 | 
			
		|||
import { HorizontalPodAutoscalerList } from 'kubernetes-types/autoscaling/v1';
 | 
			
		||||
import {
 | 
			
		||||
  HorizontalPodAutoscaler,
 | 
			
		||||
  HorizontalPodAutoscalerList,
 | 
			
		||||
} from 'kubernetes-types/autoscaling/v1';
 | 
			
		||||
import { useQuery } from 'react-query';
 | 
			
		||||
 | 
			
		||||
import axios from '@/portainer/services/axios';
 | 
			
		||||
import { EnvironmentId } from '@/react/portainer/environments/types';
 | 
			
		||||
import { withError } from '@/react-tools/react-query';
 | 
			
		||||
 | 
			
		||||
import { parseKubernetesAxiosError } from '../axiosError';
 | 
			
		||||
 | 
			
		||||
// when yaml is set to true, the expected return type is a string
 | 
			
		||||
export function useHorizontalAutoScalarQuery<
 | 
			
		||||
  T extends HorizontalPodAutoscaler | string = HorizontalPodAutoscaler
 | 
			
		||||
>(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  name?: string,
 | 
			
		||||
  options?: { yaml?: boolean }
 | 
			
		||||
) {
 | 
			
		||||
  return useQuery(
 | 
			
		||||
    [
 | 
			
		||||
      'environments',
 | 
			
		||||
      environmentId,
 | 
			
		||||
      'kubernetes',
 | 
			
		||||
      'namespaces',
 | 
			
		||||
      namespace,
 | 
			
		||||
      'horizontalpodautoscalers',
 | 
			
		||||
      name,
 | 
			
		||||
      options?.yaml,
 | 
			
		||||
    ],
 | 
			
		||||
    () =>
 | 
			
		||||
      name
 | 
			
		||||
        ? getNamespaceHorizontalPodAutoscaler<T>(
 | 
			
		||||
            environmentId,
 | 
			
		||||
            namespace,
 | 
			
		||||
            name,
 | 
			
		||||
            options
 | 
			
		||||
          )
 | 
			
		||||
        : undefined,
 | 
			
		||||
    { ...withError('Unable to get horizontal pod autoscaler'), enabled: !!name }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getNamespaceHorizontalPodAutoscalers(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string
 | 
			
		||||
) {
 | 
			
		||||
  const { data: autoScalarList } = await axios.get<HorizontalPodAutoscalerList>(
 | 
			
		||||
    `/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers`
 | 
			
		||||
  );
 | 
			
		||||
  return autoScalarList.items;
 | 
			
		||||
  try {
 | 
			
		||||
    const { data: autoScalarList } =
 | 
			
		||||
      await axios.get<HorizontalPodAutoscalerList>(
 | 
			
		||||
        `/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers`
 | 
			
		||||
      );
 | 
			
		||||
    return autoScalarList.items;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    throw parseKubernetesAxiosError(
 | 
			
		||||
      e as Error,
 | 
			
		||||
      'Unable to retrieve horizontal pod autoscalers'
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getNamespaceHorizontalPodAutoscaler<
 | 
			
		||||
  T extends HorizontalPodAutoscaler | string = HorizontalPodAutoscaler
 | 
			
		||||
>(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  name: string,
 | 
			
		||||
  options?: { yaml?: boolean }
 | 
			
		||||
) {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data: autoScalar } = await axios.get<T>(
 | 
			
		||||
      `/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers/${name}`,
 | 
			
		||||
      {
 | 
			
		||||
        headers: {
 | 
			
		||||
          Accept: options?.yaml ? 'application/yaml' : 'application/json',
 | 
			
		||||
        },
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
    return autoScalar;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    throw parseKubernetesAxiosError(
 | 
			
		||||
      e as Error,
 | 
			
		||||
      'Unable to retrieve horizontal pod autoscaler'
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,14 +25,18 @@ export async function getNamespacePods(
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getPod(
 | 
			
		||||
export async function getPod<T extends Pod | string = Pod>(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  name: string
 | 
			
		||||
  name: string,
 | 
			
		||||
  yaml?: boolean
 | 
			
		||||
) {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios.get<Pod>(
 | 
			
		||||
      buildUrl(environmentId, namespace, name)
 | 
			
		||||
    const { data } = await axios.get<T>(
 | 
			
		||||
      buildUrl(environmentId, namespace, name),
 | 
			
		||||
      {
 | 
			
		||||
        headers: { Accept: yaml ? 'application/yaml' : 'application/json' },
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,70 @@
 | 
			
		|||
import { NodeList, Node } from 'kubernetes-types/core/v1';
 | 
			
		||||
import { useQuery } from 'react-query';
 | 
			
		||||
 | 
			
		||||
import axios from '@/portainer/services/axios';
 | 
			
		||||
import { EnvironmentId } from '@/react/portainer/environments/types';
 | 
			
		||||
import { withError } from '@/react-tools/react-query';
 | 
			
		||||
 | 
			
		||||
const queryKeys = {
 | 
			
		||||
  node: (environmentId: number, nodeName: string) => [
 | 
			
		||||
    'environments',
 | 
			
		||||
    environmentId,
 | 
			
		||||
    'kubernetes',
 | 
			
		||||
    'nodes',
 | 
			
		||||
    nodeName,
 | 
			
		||||
  ],
 | 
			
		||||
  nodes: (environmentId: number) => [
 | 
			
		||||
    'environments',
 | 
			
		||||
    environmentId,
 | 
			
		||||
    'kubernetes',
 | 
			
		||||
    'nodes',
 | 
			
		||||
  ],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
async function getNode(environmentId: EnvironmentId, nodeName: string) {
 | 
			
		||||
  const { data: node } = await axios.get<Node>(
 | 
			
		||||
    `/endpoints/${environmentId}/kubernetes/api/v1/nodes/${nodeName}`
 | 
			
		||||
  );
 | 
			
		||||
  return node;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useNodeQuery(environmentId: EnvironmentId, nodeName: string) {
 | 
			
		||||
  return useQuery(
 | 
			
		||||
    queryKeys.node(environmentId, nodeName),
 | 
			
		||||
    () => getNode(environmentId, nodeName),
 | 
			
		||||
    {
 | 
			
		||||
      ...withError(
 | 
			
		||||
        'Unable to get node details from the Kubernetes api',
 | 
			
		||||
        'Failed to get node details'
 | 
			
		||||
      ),
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getNodes is used to get a list of nodes using the kubernetes API
 | 
			
		||||
async function getNodes(environmentId: EnvironmentId) {
 | 
			
		||||
  const { data: nodeList } = await axios.get<NodeList>(
 | 
			
		||||
    `/endpoints/${environmentId}/kubernetes/api/v1/nodes`
 | 
			
		||||
  );
 | 
			
		||||
  return nodeList.items;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// useNodesQuery is used to get an array of nodes using the kubernetes API
 | 
			
		||||
export function useNodesQuery(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  options?: { autoRefreshRate?: number }
 | 
			
		||||
) {
 | 
			
		||||
  return useQuery(
 | 
			
		||||
    queryKeys.nodes(environmentId),
 | 
			
		||||
    async () => getNodes(environmentId),
 | 
			
		||||
    {
 | 
			
		||||
      ...withError(
 | 
			
		||||
        'Failed to get nodes from the Kubernetes api',
 | 
			
		||||
        'Failed to get nodes'
 | 
			
		||||
      ),
 | 
			
		||||
      refetchInterval() {
 | 
			
		||||
        return options?.autoRefreshRate ?? false;
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -15,7 +15,7 @@ type Props = {
 | 
			
		|||
  tableState: TableState<TableSettings>;
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  'data-cy': string;
 | 
			
		||||
  noWidget: boolean;
 | 
			
		||||
  noWidget?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function EventsDatatable({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,9 +11,10 @@ import { BETeaserButton } from '@@/BETeaserButton';
 | 
			
		|||
type Props = {
 | 
			
		||||
  identifier: string;
 | 
			
		||||
  data: string;
 | 
			
		||||
  hideMessage?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function YAMLInspector({ identifier, data }: Props) {
 | 
			
		||||
export function YAMLInspector({ identifier, data, hideMessage }: Props) {
 | 
			
		||||
  const [expanded, setExpanded] = useState(false);
 | 
			
		||||
  const yaml = useMemo(() => cleanYamlUnwantedFields(data), [data]);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -21,7 +22,11 @@ export function YAMLInspector({ identifier, data }: Props) {
 | 
			
		|||
    <div>
 | 
			
		||||
      <WebEditorForm
 | 
			
		||||
        value={yaml}
 | 
			
		||||
        placeholder="Define or paste the content of your manifest here"
 | 
			
		||||
        placeholder={
 | 
			
		||||
          hideMessage
 | 
			
		||||
            ? undefined
 | 
			
		||||
            : 'Define or paste the content of your manifest here'
 | 
			
		||||
        }
 | 
			
		||||
        readonly
 | 
			
		||||
        hideTitle
 | 
			
		||||
        id={identifier}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,7 +6,7 @@ import { debounce } from 'lodash';
 | 
			
		|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
 | 
			
		||||
import { useConfigurations } from '@/react/kubernetes/configs/queries';
 | 
			
		||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
 | 
			
		||||
import { useServices } from '@/react/kubernetes/networks/services/queries';
 | 
			
		||||
import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries';
 | 
			
		||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
 | 
			
		||||
import { useAuthorizations } from '@/react/hooks/useUser';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -62,7 +62,7 @@ export function CreateIngressView() {
 | 
			
		|||
 | 
			
		||||
  const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
 | 
			
		||||
 | 
			
		||||
  const { data: allServices } = useServices(environmentId, namespace);
 | 
			
		||||
  const { data: allServices } = useNamespaceServices(environmentId, namespace);
 | 
			
		||||
  const configResults = useConfigurations(environmentId, namespace);
 | 
			
		||||
  const ingressesResults = useIngresses(
 | 
			
		||||
    environmentId,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,7 +6,10 @@ import { error as notifyError } from '@/portainer/services/notifications';
 | 
			
		|||
import { getServices } from './service';
 | 
			
		||||
import { Service } from './types';
 | 
			
		||||
 | 
			
		||||
export function useServices(environmentId: EnvironmentId, namespace: string) {
 | 
			
		||||
export function useNamespaceServices(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string
 | 
			
		||||
) {
 | 
			
		||||
  return useQuery(
 | 
			
		||||
    [
 | 
			
		||||
      'environments',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,11 +7,13 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
 | 
			
		|||
import { EnvironmentId } from '@/react/portainer/environments/types';
 | 
			
		||||
import { isFulfilled } from '@/portainer/helpers/promise-utils';
 | 
			
		||||
import {
 | 
			
		||||
  Service,
 | 
			
		||||
  NodeMetrics,
 | 
			
		||||
  NodeMetric,
 | 
			
		||||
  Service,
 | 
			
		||||
} from '@/react/kubernetes/services/types';
 | 
			
		||||
 | 
			
		||||
import { parseKubernetesAxiosError } from '../axiosError';
 | 
			
		||||
 | 
			
		||||
export const queryKeys = {
 | 
			
		||||
  clusterServices: (environmentId: EnvironmentId) =>
 | 
			
		||||
    ['environments', environmentId, 'kubernetes', 'services'] as const,
 | 
			
		||||
| 
						 | 
				
			
			@ -47,6 +49,31 @@ export function useServicesForCluster(
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// get a list of services, based on an array of service names, for a specific namespace
 | 
			
		||||
export function useServicesQuery<T extends Service | string = Service>(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  serviceNames: string[],
 | 
			
		||||
  options?: { yaml?: boolean }
 | 
			
		||||
) {
 | 
			
		||||
  return useQuery(
 | 
			
		||||
    ['environments', environmentId, 'kubernetes', 'services', serviceNames],
 | 
			
		||||
    async () => {
 | 
			
		||||
      // promise.all is best in this case because I want to return an error if even one service request has an error
 | 
			
		||||
      const services = await Promise.all(
 | 
			
		||||
        serviceNames.map((serviceName) =>
 | 
			
		||||
          getService<T>(environmentId, namespace, serviceName, options?.yaml)
 | 
			
		||||
        )
 | 
			
		||||
      );
 | 
			
		||||
      return services;
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      ...withError('Unable to retrieve services.'),
 | 
			
		||||
      enabled: !!serviceNames?.length,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useMutationDeleteServices(environmentId: EnvironmentId) {
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
  return useMutation(deleteServices, {
 | 
			
		||||
| 
						 | 
				
			
			@ -57,7 +84,7 @@ export function useMutationDeleteServices(environmentId: EnvironmentId) {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
// get a list of services for a specific namespace from the Portainer API
 | 
			
		||||
async function getServices(
 | 
			
		||||
export async function getServices(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  lookupApps: boolean
 | 
			
		||||
| 
						 | 
				
			
			@ -77,19 +104,45 @@ async function getServices(
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getNamespaceServices is used to get a list of services for a specific namespace directly from the Kubernetes API
 | 
			
		||||
// getNamespaceServices is used to get a list of services for a specific namespace
 | 
			
		||||
// it calls the kubernetes api directly and not the portainer api
 | 
			
		||||
export async function getNamespaceServices(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  queryParams?: Record<string, string>
 | 
			
		||||
) {
 | 
			
		||||
  const { data: services } = await axios.get<ServiceList>(
 | 
			
		||||
    `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services`,
 | 
			
		||||
    {
 | 
			
		||||
      params: queryParams,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
  return services.items;
 | 
			
		||||
  try {
 | 
			
		||||
    const { data: services } = await axios.get<ServiceList>(
 | 
			
		||||
      `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services`,
 | 
			
		||||
      {
 | 
			
		||||
        params: queryParams,
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
    return services.items;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    throw parseKubernetesAxiosError(e as Error, 'Unable to retrieve services');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getService<T extends Service | string = Service>(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  namespace: string,
 | 
			
		||||
  serviceName: string,
 | 
			
		||||
  yaml?: boolean
 | 
			
		||||
) {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data: service } = await axios.get<T>(
 | 
			
		||||
      `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services/${serviceName}`,
 | 
			
		||||
      {
 | 
			
		||||
        headers: {
 | 
			
		||||
          Accept: yaml ? 'application/yaml' : 'application/json',
 | 
			
		||||
        },
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
    return service;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    throw parseKubernetesAxiosError(e as Error, 'Unable to retrieve service');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function deleteServices({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue