diff --git a/api/http/handler/kubernetes/services.go b/api/http/handler/kubernetes/services.go index 80dfed767..9647143f0 100644 --- a/api/http/handler/kubernetes/services.go +++ b/api/http/handler/kubernetes/services.go @@ -37,7 +37,15 @@ func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Req ) } - services, err := cli.GetServices(namespace) + lookup, err := request.RetrieveBooleanQueryParameter(r, "lookupapplications", true) + if err != nil { + return httperror.BadRequest( + "Invalid lookupapplications query parameter", + err, + ) + } + + services, err := cli.GetServices(namespace, lookup) if err != nil { return httperror.InternalServerError( "Unable to retrieve services", diff --git a/api/http/models/kubernetes/application.go b/api/http/models/kubernetes/application.go new file mode 100644 index 000000000..b61422042 --- /dev/null +++ b/api/http/models/kubernetes/application.go @@ -0,0 +1,11 @@ +package kubernetes + +type ( + K8sApplication struct { + UID string `json:",omitempty"` + Name string `json:""` + Namespace string `json:",omitempty"` + Kind string `json:",omitempty"` + Labels map[string]string `json:",omitempty"` + } +) diff --git a/api/http/models/kubernetes/ingress.go b/api/http/models/kubernetes/ingress.go index ef41df2b9..62cfefdd2 100644 --- a/api/http/models/kubernetes/ingress.go +++ b/api/http/models/kubernetes/ingress.go @@ -3,6 +3,7 @@ package kubernetes import ( "errors" "net/http" + "time" ) type ( @@ -18,15 +19,17 @@ type ( K8sIngressControllers []K8sIngressController K8sIngressInfo struct { - Name string `json:"Name"` - UID string `json:"UID"` - Type string `json:"Type"` - Namespace string `json:"Namespace"` - ClassName string `json:"ClassName"` - Annotations map[string]string `json:"Annotations"` - Hosts []string `json:"Hosts"` - Paths []K8sIngressPath `json:"Paths"` - TLS []K8sIngressTLS `json:"TLS"` + Name string `json:"Name"` + UID string `json:"UID"` + Type string `json:"Type"` + Namespace string `json:"Namespace"` + ClassName string `json:"ClassName"` + Annotations map[string]string `json:"Annotations"` + Hosts []string `json:"Hosts"` + Paths []K8sIngressPath `json:"Paths"` + TLS []K8sIngressTLS `json:"TLS"` + Labels map[string]string `json:"Labels,omitempty"` + CreationDate time.Time `json:"CreationDate"` } K8sIngressTLS struct { diff --git a/api/http/models/kubernetes/services.go b/api/http/models/kubernetes/services.go index 86ab625fd..9d78007bb 100644 --- a/api/http/models/kubernetes/services.go +++ b/api/http/models/kubernetes/services.go @@ -7,17 +7,23 @@ import ( type ( K8sServiceInfo struct { - Name string `json:"Name"` - UID string `json:"UID"` - Type string `json:"Type"` - Namespace string `json:"Namespace"` - Annotations map[string]string `json:"Annotations"` - CreationTimestamp string `json:"CreationTimestamp"` - Labels map[string]string `json:"Labels"` - AllocateLoadBalancerNodePorts *bool `json:"AllocateLoadBalancerNodePorts,omitempty"` - Ports []K8sServicePort `json:"Ports"` - Selector map[string]string `json:"Selector"` - IngressStatus []K8sServiceIngress `json:"IngressStatus"` + Name string + UID string + Type string + Namespace string + Annotations map[string]string + CreationTimestamp string + Labels map[string]string + AllocateLoadBalancerNodePorts *bool `json:",omitempty"` + Ports []K8sServicePort + Selector map[string]string + IngressStatus []K8sServiceIngress `json:",omitempty"` + + // serviceList screen + Applications []K8sApplication `json:",omitempty"` + ClusterIPs []string `json:",omitempty"` + ExternalName string `json:",omitempty"` + ExternalIPs []string `json:",omitempty"` } K8sServicePort struct { @@ -25,7 +31,7 @@ type ( NodePort int `json:"NodePort"` Port int `json:"Port"` Protocol string `json:"Protocol"` - TargetPort int `json:"TargetPort"` + TargetPort string `json:"TargetPort"` } K8sServiceIngress struct { diff --git a/api/kubernetes/cli/ingress.go b/api/kubernetes/cli/ingress.go index 142e3f589..b68c1f344 100644 --- a/api/kubernetes/cli/ingress.go +++ b/api/kubernetes/cli/ingress.go @@ -102,6 +102,8 @@ func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo, } info.Type = classes[info.ClassName] info.Annotations = ingress.Annotations + info.Labels = ingress.Labels + info.CreationDate = ingress.CreationTimestamp.Time // Gather TLS information. for _, v := range ingress.Spec.TLS { diff --git a/api/kubernetes/cli/service.go b/api/kubernetes/cli/service.go index fa6b507fd..c75c631b0 100644 --- a/api/kubernetes/cli/service.go +++ b/api/kubernetes/cli/service.go @@ -6,11 +6,12 @@ import ( models "github.com/portainer/portainer/api/http/models/kubernetes" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/intstr" ) // GetServices gets all the services for a given namespace in a k8s endpoint. -func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, error) { +func (kcl *KubeClient) GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error) { client := kcl.cli.CoreV1().Services(namespace) services, err := client.List(context.Background(), metav1.ListOptions{}) @@ -28,7 +29,7 @@ func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, e NodePort: int(port.NodePort), Port: int(port.Port), Protocol: string(port.Protocol), - TargetPort: port.TargetPort.IntValue(), + TargetPort: port.TargetPort.String(), }) } @@ -40,6 +41,11 @@ func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, e }) } + var applications []models.K8sApplication + if lookupApplications { + applications, _ = kcl.getOwningApplication(namespace, service.Spec.Selector) + } + result = append(result, models.K8sServiceInfo{ Name: service.Name, UID: string(service.GetUID()), @@ -51,6 +57,10 @@ func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, e IngressStatus: ingressStatus, Labels: service.GetLabels(), Annotations: service.GetAnnotations(), + ClusterIPs: service.Spec.ClusterIPs, + ExternalName: service.Spec.ExternalName, + ExternalIPs: service.Spec.ExternalIPs, + Applications: applications, }) } @@ -77,7 +87,7 @@ func (kcl *KubeClient) CreateService(namespace string, info models.K8sServiceInf port.NodePort = int32(p.NodePort) port.Port = int32(p.Port) port.Protocol = v1.Protocol(p.Protocol) - port.TargetPort = intstr.FromInt(p.TargetPort) + port.TargetPort = intstr.FromString(p.TargetPort) service.Spec.Ports = append(service.Spec.Ports, port) } @@ -133,7 +143,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf port.NodePort = int32(p.NodePort) port.Port = int32(p.Port) port.Protocol = v1.Protocol(p.Protocol) - port.TargetPort = intstr.FromInt(p.TargetPort) + port.TargetPort = intstr.FromString(p.TargetPort) service.Spec.Ports = append(service.Spec.Ports, port) } @@ -151,3 +161,54 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf _, err := ServiceClient.Update(context.Background(), &service, metav1.UpdateOptions{}) return err } + +// getOwningApplication gets the application that owns the given service selector. +func (kcl *KubeClient) getOwningApplication(namespace string, selector map[string]string) ([]models.K8sApplication, error) { + if len(selector) == 0 { + return nil, nil + } + + selectorLabels := labels.SelectorFromSet(selector).String() + + // look for replicasets first, limit 1 (we only support one owner) + replicasets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: selectorLabels, Limit: 1}) + if err != nil { + return nil, err + } + + var meta metav1.Object + if replicasets != nil && len(replicasets.Items) > 0 { + meta = replicasets.Items[0].GetObjectMeta() + } else { + // otherwise look for matching pods, limit 1 (we only support one owner) + pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: selectorLabels, Limit: 1}) + if err != nil { + return nil, err + } + + if pods == nil || len(pods.Items) == 0 { + return nil, nil + } + + meta = pods.Items[0].GetObjectMeta() + } + + return makeApplication(meta), nil +} + +func makeApplication(meta metav1.Object) []models.K8sApplication { + ownerReferences := meta.GetOwnerReferences() + if len(ownerReferences) == 0 { + return nil + } + + // Currently, we only support one owner reference + ownerReference := ownerReferences[0] + return []models.K8sApplication{ + { + // Only the name is used right now, but we can add more fields in the future + Name: ownerReference.Name, + }, + } + +} diff --git a/api/portainer.go b/api/portainer.go index 63f5f4557..918c32aaa 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1429,7 +1429,7 @@ type ( DeleteIngresses(reqs models.K8sIngressDeleteRequests) error CreateService(namespace string, service models.K8sServiceInfo) error UpdateService(namespace string, service models.K8sServiceInfo) error - GetServices(namespace string) ([]models.K8sServiceInfo, error) + GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error) DeleteServices(reqs models.K8sServiceDeleteRequests) error GetNodesLimits() (K8sNodesLimits, error) GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index ff8a098a5..f3edc768d 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -66,6 +66,16 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo }, }; + const services = { + name: 'kubernetes.services', + url: '/services', + views: { + 'content@': { + component: 'kubernetesServicesView', + }, + }, + }; + const ingresses = { name: 'kubernetes.ingresses', url: '/ingresses', @@ -406,6 +416,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo $stateRegistryProvider.register(endpointKubernetesConfiguration); $stateRegistryProvider.register(endpointKubernetesSecurityConstraint); + $stateRegistryProvider.register(services); $stateRegistryProvider.register(ingresses); $stateRegistryProvider.register(ingressesCreate); $stateRegistryProvider.register(ingressesEdit); diff --git a/app/kubernetes/components/datatables/applications-ports-datatable/applicationsPortsDatatable.html b/app/kubernetes/components/datatables/applications-ports-datatable/applicationsPortsDatatable.html deleted file mode 100644 index 0974a6928..000000000 --- a/app/kubernetes/components/datatables/applications-ports-datatable/applicationsPortsDatatable.html +++ /dev/null @@ -1,282 +0,0 @@ -
- -
-
- -
-
- -
- Port mappings -
- - -
- - - - - - -
-
- -
- - - System resources are hidden, this can be changed in the table settings. - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
-
- - - - - - - - - -
-
- - -
-
- {{ item.Name }} - system - external - - - - LoadBalancer - - {{ item.LoadBalancerIPAddress }} - pending - - - - ClusterIP - - NodePort - - - {{ item.Ports[0].Port }} - - access - - - - {{ item.Ports[0].TargetPort }}/{{ item.Ports[0].Protocol }} - - - - - - pending - - - - {{ $ctrl.buildIngressRuleURL(item.Ports[0].IngressRules[0]) | stripprotocol }} - - - - -
-- - {{ port.Port }} - - access - - {{ port.TargetPort }}/{{ port.Protocol }}-
-- - {{ port.Port }} - - access - - {{ port.TargetPort }}/{{ port.Protocol }} - pending - - - - {{ $ctrl.buildIngressRuleURL(rule) | stripprotocol }} - - -
Loading...
No application port mapping available.
-
- -
diff --git a/app/kubernetes/components/datatables/applications-ports-datatable/applicationsPortsDatatable.js b/app/kubernetes/components/datatables/applications-ports-datatable/applicationsPortsDatatable.js deleted file mode 100644 index 139b000c5..000000000 --- a/app/kubernetes/components/datatables/applications-ports-datatable/applicationsPortsDatatable.js +++ /dev/null @@ -1,13 +0,0 @@ -angular.module('portainer.kubernetes').component('kubernetesApplicationsPortsDatatable', { - templateUrl: './applicationsPortsDatatable.html', - controller: 'KubernetesApplicationsPortsDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<', - refreshCallback: '<', - }, -}); diff --git a/app/kubernetes/components/datatables/applications-ports-datatable/applicationsPortsDatatableController.js b/app/kubernetes/components/datatables/applications-ports-datatable/applicationsPortsDatatableController.js deleted file mode 100644 index 67fc81cb2..000000000 --- a/app/kubernetes/components/datatables/applications-ports-datatable/applicationsPortsDatatableController.js +++ /dev/null @@ -1,118 +0,0 @@ -import _ from 'lodash-es'; -import { KubernetesApplicationDeploymentTypes } from 'Kubernetes/models/application/models'; -import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; -import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { KubernetesServiceTypes } from 'Kubernetes/models/service/models'; - -angular.module('portainer.docker').controller('KubernetesApplicationsPortsDatatableController', [ - '$scope', - '$controller', - 'DatatableService', - 'Authentication', - function ($scope, $controller, DatatableService, Authentication) { - angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); - this.state = Object.assign(this.state, { - expandedItems: [], - expandAll: false, - }); - - var ctrl = this; - this.KubernetesServiceTypes = KubernetesServiceTypes; - - this.settings = Object.assign(this.settings, { - showSystem: false, - }); - - this.onSettingsShowSystemChange = function () { - DatatableService.setDataTableSettings(this.tableKey, this.settings); - }; - - this.isExternalApplication = function (item) { - return KubernetesApplicationHelper.isExternalApplication(item); - }; - - this.isSystemNamespace = function (item) { - return KubernetesNamespaceHelper.isSystemNamespace(item.ResourcePool); - }; - - this.isDisplayed = function (item) { - return !ctrl.isSystemNamespace(item) || ctrl.settings.showSystem; - }; - - this.expandItem = function (item, expanded) { - if (!this.itemCanExpand(item)) { - return; - } - - item.Expanded = expanded; - if (!expanded) { - item.Highlighted = false; - } - }; - - this.itemCanExpand = function (item) { - return item.Ports.length > 1 || item.Ports[0].IngressRules.length > 1; - }; - - this.buildIngressRuleURL = function (rule) { - const hostname = rule.Host ? rule.Host : rule.IP; - return 'http://' + hostname + rule.Path; - }; - - this.portHasIngressRules = function (port) { - return port.IngressRules.length > 0; - }; - - this.ruleCanBeDisplayed = function (rule) { - return !rule.Host && !rule.IP ? false : true; - }; - - this.hasExpandableItems = function () { - return _.filter(this.state.filteredDataSet, (item) => this.itemCanExpand(item)).length; - }; - - this.expandAll = function () { - this.state.expandAll = !this.state.expandAll; - _.forEach(this.state.filteredDataSet, (item) => { - if (this.itemCanExpand(item)) { - this.expandItem(item, this.state.expandAll); - } - }); - }; - - this.$onInit = function () { - this.isAdmin = Authentication.isAdmin(); - this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; - this.setDefaults(); - this.prepareTableFromDataset(); - - this.state.orderBy = this.orderBy; - var storedOrder = DatatableService.getDataTableOrder(this.tableKey); - if (storedOrder !== null) { - this.state.reverseOrder = storedOrder.reverse; - this.state.orderBy = storedOrder.orderBy; - } - - var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); - if (textFilter !== null) { - this.state.textFilter = textFilter; - this.onTextFilterChange(); - } - - var storedFilters = DatatableService.getDataTableFilters(this.tableKey); - if (storedFilters !== null) { - this.filters = storedFilters; - } - if (this.filters && this.filters.state) { - this.filters.state.open = false; - } - - var storedSettings = DatatableService.getDataTableSettings(this.tableKey); - if (storedSettings !== null) { - this.settings = storedSettings; - this.settings.open = false; - } - this.onSettingsRepeaterChange(); - }; - }, -]); diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 89e9b14c3..ac2509366 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -6,9 +6,14 @@ import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable'; import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView'; +import { ServicesView } from '@/react/kubernetes/ServicesView'; export const viewsModule = angular .module('portainer.kubernetes.react.views', []) + .component( + 'kubernetesServicesView', + r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), []) + ) .component( 'kubernetesIngressesView', r2a( diff --git a/app/kubernetes/views/applications/applications.html b/app/kubernetes/views/applications/applications.html index 2d90ac67e..f36252acf 100644 --- a/app/kubernetes/views/applications/applications.html +++ b/app/kubernetes/views/applications/applications.html @@ -22,11 +22,6 @@ > - - Port mappings - - - Stacks ) { return (

- + {children}

); diff --git a/app/react/components/datatables/types.ts b/app/react/components/datatables/types.ts index 7629dd250..0c396f1b1 100644 --- a/app/react/components/datatables/types.ts +++ b/app/react/components/datatables/types.ts @@ -8,7 +8,7 @@ export interface PaginationTableSettings { setPageSize: (pageSize: number) => void; } -type ZustandSetFunc = ( +export type ZustandSetFunc = ( partial: T | Partial | ((state: T) => T | Partial), replace?: boolean | undefined ) => void; diff --git a/app/react/components/modals/confirm.ts b/app/react/components/modals/confirm.ts index e0d4a5265..4ca4a7e91 100644 --- a/app/react/components/modals/confirm.ts +++ b/app/react/components/modals/confirm.ts @@ -1,3 +1,5 @@ +import { ReactNode } from 'react'; + import { openDialog, DialogOptions } from './Dialog'; import { OnSubmit, ModalType } from './Modal'; import { ButtonOptions } from './types'; @@ -45,7 +47,7 @@ export function confirmWebEditorDiscard() { }); } -export function confirmDelete(message: string) { +export function confirmDelete(message: ReactNode) { return confirmDestructive({ title: 'Are you sure?', message, diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/ServicesDatatable.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/ServicesDatatable.tsx new file mode 100644 index 000000000..362419397 --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/ServicesDatatable.tsx @@ -0,0 +1,190 @@ +import { Row, TableRowProps } from 'react-table'; +import { Shuffle, Trash2 } from 'lucide-react'; +import { useStore } from 'zustand'; +import { useRouter } from '@uirouter/react'; +import clsx from 'clsx'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { + Authorized, + useAuthorizations, + useCurrentUser, +} from '@/react/hooks/useUser'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; + +import { Datatable, Table, TableSettingsMenu } from '@@/datatables'; +import { confirmDelete } from '@@/modals/confirm'; +import { useSearchBarState } from '@@/datatables/SearchBar'; +import { Button } from '@@/buttons'; +import { Link } from '@@/Link'; + +import { useMutationDeleteServices, useServices } from '../service'; +import { Service } from '../types'; +import { DefaultDatatableSettings } from '../../datatables/DefaultDatatableSettings'; + +import { useColumns } from './columns'; +import { createStore } from './datatable-store'; +import { ServicesDatatableDescription } from './ServicesDatatableDescription'; + +const storageKey = 'k8sServicesDatatable'; +const settingsStore = createStore(storageKey); + +export function ServicesDatatable() { + const environmentId = useEnvironmentId(); + const servicesQuery = useServices(environmentId); + + const settings = useStore(settingsStore); + + const [search, setSearch] = useSearchBarState(storageKey); + const columns = useColumns(); + const readOnly = !useAuthorizations(['K8sServiceW']); + const { isAdmin } = useCurrentUser(); + + const filteredServices = servicesQuery.data?.filter( + (service) => + (isAdmin && settings.showSystemResources) || + !KubernetesNamespaceHelper.isSystemNamespace(service.Namespace) + ); + + return ( + row.UID} + isRowSelectable={(row) => + !KubernetesNamespaceHelper.isSystemNamespace(row.values.namespace) + } + disableSelect={readOnly} + renderTableActions={(selectedRows) => ( + + )} + initialPageSize={settings.pageSize} + onPageSizeChange={settings.setPageSize} + initialSortBy={settings.sortBy} + onSortByChange={settings.setSortBy} + searchValue={search} + onSearchChange={setSearch} + renderTableSettings={() => ( + + + + )} + description={ + + } + renderRow={servicesRenderRow} + /> + ); +} + +// needed to apply custom styling to the row cells and not globally. +// required in the AC's for this ticket. +function servicesRenderRow>( + row: Row, + rowProps: TableRowProps, + highlightedItemId?: string +) { + return ( + + key={rowProps.key} + cells={row.cells} + className={clsx('[&>td]:!py-4 [&>td]:!align-top', rowProps.className, { + active: highlightedItemId === row.id, + })} + role={rowProps.role} + style={rowProps.style} + /> + ); +} + +interface SelectedService { + Namespace: string; + Name: string; +} + +type TableActionsProps = { + selectedItems: Service[]; +}; + +function TableActions({ selectedItems }: TableActionsProps) { + const environmentId = useEnvironmentId(); + const deleteServicesMutation = useMutationDeleteServices(environmentId); + const router = useRouter(); + + async function handleRemoveClick(services: SelectedService[]) { + const confirmed = await confirmDelete( + <> +

Are you sure you want to delete the selected service(s)?

+
    + {services.map((s, index) => ( +
  • + {s.Namespace}/{s.Name} +
  • + ))} +
+ + ); + if (!confirmed) { + return null; + } + + const payload: Record = {}; + services.forEach((service) => { + payload[service.Namespace] = payload[service.Namespace] || []; + payload[service.Namespace].push(service.Name); + }); + + deleteServicesMutation.mutate( + { environmentId, data: payload }, + { + onSuccess: () => { + notifySuccess( + 'Services successfully removed', + services.map((s) => `${s.Namespace}/${s.Name}`).join(', ') + ); + router.stateService.reload(); + }, + onError: (error) => { + notifyError( + 'Unable to delete service(s)', + error as Error, + services.map((s) => `${s.Namespace}/${s.Name}`).join(', ') + ); + }, + } + ); + return services; + } + + return ( +
+ + + + + + + +
+ ); +} diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/ServicesDatatableDescription.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/ServicesDatatableDescription.tsx new file mode 100644 index 000000000..536db312c --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/ServicesDatatableDescription.tsx @@ -0,0 +1,17 @@ +import { TextTip } from '@@/Tip/TextTip'; + +interface Props { + showSystemResources: boolean; +} + +export function ServicesDatatableDescription({ showSystemResources }: Props) { + return ( +
+ {!showSystemResources && ( + + System resources are hidden, this can be changed in the table settings + + )} +
+ ); +} diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/application.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/application.tsx new file mode 100644 index 000000000..01b7c660c --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/application.tsx @@ -0,0 +1,34 @@ +import { CellProps, Column } from 'react-table'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { Link } from '@@/Link'; + +import { Service } from '../../types'; + +export const application: Column = { + Header: 'Application', + accessor: (row) => (row.Applications ? row.Applications[0].Name : ''), + id: 'application', + + Cell: ({ row, value: appname }: CellProps) => { + const environmentId = useEnvironmentId(); + return appname ? ( + + {appname} + + ) : ( + '-' + ); + }, + canHide: true, + disableFilters: true, +}; diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/clusterIP.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/clusterIP.tsx new file mode 100644 index 000000000..e042ade4c --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/clusterIP.tsx @@ -0,0 +1,41 @@ +import { CellProps, Column } from 'react-table'; + +import { Service } from '../../types'; + +export const clusterIP: Column = { + Header: 'Cluster IP', + accessor: 'ClusterIPs', + id: 'clusterIP', + Cell: ({ value: clusterIPs }: CellProps) => { + if (!clusterIPs?.length) { + return '-'; + } + return clusterIPs.map((ip) =>
{ip}
); + }, + disableFilters: true, + canHide: true, + sortType: (rowA, rowB) => { + const a = rowA.original.ClusterIPs; + const b = rowB.original.ClusterIPs; + + const ipA = a?.[0]; + const ipB = b?.[0]; + + // no ip's at top, followed by 'None', then ordered by ip + if (!ipA) return 1; + if (!ipB) return -1; + if (ipA === ipB) return 0; + if (ipA === 'None') return 1; + if (ipB === 'None') return -1; + + // natural sort of the ip + return ipA.localeCompare( + ipB, + navigator.languages[0] || navigator.language, + { + numeric: true, + ignorePunctuation: true, + } + ); + }, +}; diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/created.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/created.tsx new file mode 100644 index 000000000..dad42ff2a --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/created.tsx @@ -0,0 +1,23 @@ +import { CellProps, Column } from 'react-table'; + +import { formatDate } from '@/portainer/filters/filters'; + +import { Service } from '../../types'; + +export const created: Column = { + Header: 'Created', + id: 'created', + accessor: (row) => row.CreationTimestamp, + Cell: ({ row }: CellProps) => { + const owner = + row.original.Labels?.['io.portainer.kubernetes.application.owner']; + + if (owner) { + return `${formatDate(row.original.CreationTimestamp)} by ${owner}`; + } + + return formatDate(row.original.CreationTimestamp); + }, + disableFilters: true, + canHide: true, +}; diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/externalIP.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/externalIP.tsx new file mode 100644 index 000000000..feb46128e --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/externalIP.tsx @@ -0,0 +1,136 @@ +import { CellProps, Column } from 'react-table'; + +import { Service } from '../../types'; + +import { ExternalIPLink } from './externalIPLink'; + +// calculate the scheme based on the ports of the service +// favour https over http. +function getSchemeAndPort(svc: Service): [string, number] { + let scheme = ''; + let servicePort = 0; + + svc.Ports?.forEach((port) => { + if (port.Protocol === 'TCP') { + switch (port.TargetPort) { + case '443': + case '8443': + case 'https': + scheme = 'https'; + servicePort = port.Port; + break; + + case '80': + case '8080': + case 'http': + if (scheme !== 'https') { + scheme = 'http'; + servicePort = port.Port; + } + break; + + default: + break; + } + } + }); + + return [scheme, servicePort]; +} + +export const externalIP: Column = { + Header: 'External IP', + id: 'externalIP', + accessor: (row) => { + if (row.Type === 'ExternalName') { + return row.ExternalName; + } + + if (row.ExternalIPs?.length) { + return row.ExternalIPs?.slice(0); + } + + return row.IngressStatus?.slice(0); + }, + Cell: ({ row }: CellProps) => { + if (row.original.Type === 'ExternalName') { + if (row.original.ExternalName) { + const linkto = `http://${row.original.ExternalName}`; + return ; + } + return '-'; + } + + const [scheme, port] = getSchemeAndPort(row.original); + if (row.original.ExternalIPs?.length) { + return row.original.ExternalIPs?.map((ip, index) => { + // some ips come through blank + if (ip.length === 0) { + return '-'; + } + + if (scheme) { + let linkto = `${scheme}://${ip}`; + if (port !== 80 && port !== 443) { + linkto = `${linkto}:${port}`; + } + return ( +
+ +
+ ); + } + return
{ip}
; + }); + } + + const status = row.original.IngressStatus; + if (status) { + return status?.map((status, index) => { + // some ips come through blank + if (status.IP.length === 0) { + return '-'; + } + + if (scheme) { + let linkto = `${scheme}://${status.IP}`; + if (port !== 80 && port !== 443) { + linkto = `${linkto}:${port}`; + } + return ( +
+ +
+ ); + } + return
{status.IP}
; + }); + } + + return '-'; + }, + disableFilters: true, + canHide: true, + sortType: (rowA, rowB) => { + const a = rowA.original.IngressStatus; + const b = rowB.original.IngressStatus; + const aExternal = rowA.original.ExternalIPs; + const bExternal = rowB.original.ExternalIPs; + + const ipA = a?.[0].IP || aExternal?.[0] || rowA.original.ExternalName; + const ipB = b?.[0].IP || bExternal?.[0] || rowA.original.ExternalName; + + if (!ipA) return 1; + if (!ipB) return -1; + + // use a nat sort order for ip addresses + return ipA.localeCompare( + ipB, + navigator.languages[0] || navigator.language, + { + numeric: true, + ignorePunctuation: true, + } + ); + }, +}; diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/externalIPLink.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/externalIPLink.tsx new file mode 100644 index 000000000..cf6414529 --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/externalIPLink.tsx @@ -0,0 +1,22 @@ +import { ExternalLink } from 'lucide-react'; + +import { Icon } from '@@/Icon'; + +interface Props { + to: string; + text: string; +} + +export function ExternalIPLink({ to, text }: Props) { + return ( + + + {text} + + ); +} diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/index.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/index.tsx new file mode 100644 index 000000000..7c310cd56 --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/index.tsx @@ -0,0 +1,23 @@ +import { name } from './name'; +import { type } from './type'; +import { namespace } from './namespace'; +import { ports } from './ports'; +import { clusterIP } from './clusterIP'; +import { externalIP } from './externalIP'; +import { targetPorts } from './targetPorts'; +import { application } from './application'; +import { created } from './created'; + +export function useColumns() { + return [ + name, + application, + namespace, + type, + ports, + targetPorts, + clusterIP, + externalIP, + created, + ]; +} diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/name.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/name.tsx new file mode 100644 index 000000000..134d3ac47 --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/name.tsx @@ -0,0 +1,45 @@ +import { CellProps, Column } from 'react-table'; + +import { Authorized } from '@/react/hooks/useUser'; +import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper'; + +import { Service } from '../../types'; + +export const name: Column = { + Header: 'Name', + id: 'Name', + accessor: (row) => row.Name, + Cell: ({ row }: CellProps) => { + const isSystem = KubernetesNamespaceHelper.isSystemNamespace( + row.original.Namespace + ); + + const isExternal = + !row.original.Labels || + !row.original.Labels['io.portainer.kubernetes.application.owner']; + + return ( + + {row.original.Name} + + {isSystem && ( + + system + + )} + + {isExternal && !isSystem && ( + + external + + )} + + ); + }, + + disableFilters: true, + canHide: true, +}; diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/namespace.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/namespace.tsx new file mode 100644 index 000000000..a07aa2d9e --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/namespace.tsx @@ -0,0 +1,33 @@ +import { CellProps, Column, Row } from 'react-table'; + +import { filterHOC } from '@/react/components/datatables/Filter'; + +import { Link } from '@@/Link'; + +import { Service } from '../../types'; + +export const namespace: Column = { + Header: 'Namespace', + id: 'namespace', + accessor: 'Namespace', + Cell: ({ row }: CellProps) => ( + + {row.original.Namespace} + + ), + canHide: true, + disableFilters: false, + Filter: filterHOC('Filter by namespace'), + filter: (rows: Row[], _filterValue, filters) => { + if (filters.length === 0) { + return rows; + } + return rows.filter((r) => filters.includes(r.original.Namespace)); + }, +}; diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/ports.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/ports.tsx new file mode 100644 index 000000000..03fbf4de2 --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/ports.tsx @@ -0,0 +1,76 @@ +import { CellProps, Column } from 'react-table'; + +import { Tooltip } from '@@/Tip/Tooltip'; + +import { Service } from '../../types'; + +export const ports: Column = { + Header: () => ( + <> + Ports + + + ), + + id: 'ports', + accessor: (row) => { + const ports = row.Ports; + return ports.map( + (port) => `${port.Port}:${port.NodePort}/${port.Protocol}` + ); + }, + Cell: ({ row }: CellProps) => { + if (!row.original.Ports.length) { + return '-'; + } + + return ( + <> + {row.original.Ports.map((port, index) => { + if (port.NodePort !== 0) { + return ( +
+ {port.Port}:{port.NodePort}/{port.Protocol} +
+ ); + } + + return ( +
+ {port.Port}/{port.Protocol} +
+ ); + })} + + ); + }, + disableFilters: true, + canHide: true, + + sortType: (rowA, rowB) => { + const a = rowA.original.Ports; + const b = rowB.original.Ports; + + if (!a.length && !b.length) return 0; + + if (!a.length) return 1; + if (!b.length) return -1; + + // sort order based on first port + const portA = a[0].Port; + const portB = b[0].Port; + + if (portA === portB) { + // longer list of ports is considered "greater" + if (a.length < b.length) return -1; + if (a.length > b.length) return 1; + return 0; + } + + // now do a regular number sort + if (portA < portB) return -1; + if (portA > portB) return 1; + + return 0; + }, +}; diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/targetPorts.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/targetPorts.tsx new file mode 100644 index 000000000..f33b63c66 --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/targetPorts.tsx @@ -0,0 +1,53 @@ +import { CellProps, Column } from 'react-table'; + +import { Service } from '../../types'; + +export const targetPorts: Column = { + Header: 'Target Ports', + id: 'targetPorts', + accessor: (row) => { + const ports = row.Ports; + if (!ports.length) { + return '-'; + } + return ports.map((port) => `${port.TargetPort}`); + }, + Cell: ({ row }: CellProps) => { + const ports = row.original.Ports; + if (!ports.length) { + return '-'; + } + return ports.map((port, index) =>
{port.TargetPort}
); + }, + disableFilters: true, + canHide: true, + + sortType: (rowA, rowB) => { + const a = rowA.original.Ports; + const b = rowB.original.Ports; + + if (!a.length && !b.length) return 0; + if (!a.length) return 1; + if (!b.length) return -1; + + const portA = a[0].TargetPort; + const portB = b[0].TargetPort; + + if (portA === portB) { + if (a.length < b.length) return -1; + if (a.length > b.length) return 1; + + return 0; + } + + // natural sort of the port + return portA.localeCompare( + portB, + navigator.languages[0] || navigator.language, + { + numeric: true, + ignorePunctuation: true, + } + ); + }, +}; diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/type.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/type.tsx new file mode 100644 index 000000000..ff7411eed --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/columns/type.tsx @@ -0,0 +1,22 @@ +import { CellProps, Column, Row } from 'react-table'; + +import { filterHOC } from '@@/datatables/Filter'; + +import { Service } from '../../types'; + +export const type: Column = { + Header: 'Type', + id: 'type', + accessor: (row) => row.Type, + Cell: ({ row }: CellProps) =>
{row.original.Type}
, + canHide: true, + + disableFilters: false, + Filter: filterHOC('Filter by type'), + filter: (rows: Row[], _filterValue, filters) => { + if (filters.length === 0) { + return rows; + } + return rows.filter((r) => filters.includes(r.original.Type)); + }, +}; diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/datatable-store.ts b/app/react/kubernetes/ServicesView/ServicesDatatable/datatable-store.ts new file mode 100644 index 000000000..9586395e0 --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/datatable-store.ts @@ -0,0 +1,13 @@ +import { refreshableSettings, createPersistedStore } from '@@/datatables/types'; + +import { + systemResourcesSettings, + TableSettings, +} from '../../datatables/DefaultDatatableSettings'; + +export function createStore(storageKey: string) { + return createPersistedStore(storageKey, 'Name', (set) => ({ + ...refreshableSettings(set), + ...systemResourcesSettings(set), + })); +} diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/index.tsx b/app/react/kubernetes/ServicesView/ServicesDatatable/index.tsx new file mode 100644 index 000000000..2c44e471e --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesDatatable/index.tsx @@ -0,0 +1 @@ +export { ServicesDatatable } from './ServicesDatatable'; diff --git a/app/react/kubernetes/ServicesView/ServicesView.tsx b/app/react/kubernetes/ServicesView/ServicesView.tsx new file mode 100644 index 000000000..344e4feae --- /dev/null +++ b/app/react/kubernetes/ServicesView/ServicesView.tsx @@ -0,0 +1,12 @@ +import { PageHeader } from '@@/PageHeader'; + +import { ServicesDatatable } from './ServicesDatatable'; + +export function ServicesView() { + return ( + <> + + + + ); +} diff --git a/app/react/kubernetes/ServicesView/index.ts b/app/react/kubernetes/ServicesView/index.ts new file mode 100644 index 000000000..567e7e19c --- /dev/null +++ b/app/react/kubernetes/ServicesView/index.ts @@ -0,0 +1 @@ +export { ServicesView } from './ServicesView'; diff --git a/app/react/kubernetes/ServicesView/service.ts b/app/react/kubernetes/ServicesView/service.ts new file mode 100644 index 000000000..20f11d62a --- /dev/null +++ b/app/react/kubernetes/ServicesView/service.ts @@ -0,0 +1,84 @@ +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { compact } from 'lodash'; + +import { withError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { getNamespaces } from '../namespaces/service'; + +export const queryKeys = { + list: (environmentId: EnvironmentId) => + ['environments', environmentId, 'kubernetes', 'services'] as const, +}; + +async function getServices( + environmentId: EnvironmentId, + namespace: string, + lookupApps: boolean +) { + try { + const { data: services } = await axios.get( + `kubernetes/${environmentId}/namespaces/${namespace}/services`, + { + params: { + lookupapplications: lookupApps, + }, + } + ); + return services; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve services'); + } +} + +export function useServices(environmentId: EnvironmentId) { + return useQuery( + queryKeys.list(environmentId), + async () => { + const namespaces = await getNamespaces(environmentId); + const settledServicesPromise = await Promise.allSettled( + Object.keys(namespaces).map((namespace) => + getServices(environmentId, namespace, true) + ) + ); + return compact( + settledServicesPromise.filter(isFulfilled).flatMap((i) => i.value) + ); + }, + withError('Unable to get services.') + ); +} + +function isFulfilled( + input: PromiseSettledResult +): input is PromiseFulfilledResult { + return input.status === 'fulfilled'; +} + +export function useMutationDeleteServices(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + return useMutation(deleteServices, { + onSuccess: () => + // use the exact same query keys as the useServices hook to invalidate the services list + queryClient.invalidateQueries(queryKeys.list(environmentId)), + ...withError('Unable to delete service(s)'), + }); +} + +export async function deleteServices({ + environmentId, + data, +}: { + environmentId: EnvironmentId; + data: Record; +}) { + try { + return await axios.post( + `kubernetes/${environmentId}/services/delete`, + data + ); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to delete service(s)'); + } +} diff --git a/app/react/kubernetes/ServicesView/types.ts b/app/react/kubernetes/ServicesView/types.ts new file mode 100644 index 000000000..6971b4e4c --- /dev/null +++ b/app/react/kubernetes/ServicesView/types.ts @@ -0,0 +1,41 @@ +type ServicePort = { + Name: string; + NodePort: number; + Port: number; + Protocol: string; + TargetPort: string; +}; + +type IngressStatus = { + Hostname: string; + IP: string; +}; + +type Application = { + UID: string; + Name: string; + Type: string; +}; + +export type ServiceType = + | 'ClusterIP' + | 'ExternalName' + | 'NodePort' + | 'LoadBalancer'; + +export type Service = { + Name: string; + UID: string; + Namespace: string; + Annotations?: Record; + Labels?: Record; + Type: ServiceType; + Ports: Array; + Selector?: Record; + ClusterIPs?: Array; + IngressStatus?: Array; + ExternalName?: string; + ExternalIPs?: Array; + CreationTimestamp: string; + Applications?: Application[]; +}; diff --git a/app/react/kubernetes/datatables/DefaultDatatableSettings.tsx b/app/react/kubernetes/datatables/DefaultDatatableSettings.tsx new file mode 100644 index 000000000..260379178 --- /dev/null +++ b/app/react/kubernetes/datatables/DefaultDatatableSettings.tsx @@ -0,0 +1,61 @@ +import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; +import { Checkbox } from '@@/form-components/Checkbox'; +import { + BasicTableSettings, + RefreshableTableSettings, + ZustandSetFunc, +} from '@@/datatables/types'; + +interface SystemResourcesTableSettings { + showSystemResources: boolean; + setShowSystemResources: (value: boolean) => void; +} + +export interface TableSettings + extends BasicTableSettings, + RefreshableTableSettings, + SystemResourcesTableSettings {} + +export function systemResourcesSettings( + set: ZustandSetFunc +): SystemResourcesTableSettings { + return { + showSystemResources: false, + setShowSystemResources(showSystemResources: boolean) { + set({ + showSystemResources, + }); + }, + }; +} + +interface Props { + settings: TableSettings; + hideShowSystemResources?: boolean; +} + +export function DefaultDatatableSettings({ + settings, + hideShowSystemResources = false, +}: Props) { + return ( + <> + {!hideShowSystemResources && ( + settings.setShowSystemResources(e.target.checked)} + /> + )} + + + ); + + function handleRefreshRateChange(autoRefreshRate: number) { + settings.setAutoRefreshRate(autoRefreshRate); + } +} diff --git a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx index ea189040e..0d67f69a4 100644 --- a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx +++ b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx @@ -17,7 +17,7 @@ import { useSearchBarState } from '@@/datatables/SearchBar'; import { DeleteIngressesRequest, Ingress } from '../types'; import { useDeleteIngresses, useIngresses } from '../queries'; -import { useColumns } from './columns'; +import { columns } from './columns'; import '../style.css'; @@ -38,7 +38,6 @@ export function IngressDatatable() { Object.keys(nsResult?.data || {}) ); - const columns = useColumns(); const deleteIngressesMutation = useDeleteIngresses(); const settings = useStore(settingsStore); const [search, setSearch] = useSearchBarState(storageKey); diff --git a/app/react/kubernetes/ingresses/IngressDatatable/columns/created.tsx b/app/react/kubernetes/ingresses/IngressDatatable/columns/created.tsx new file mode 100644 index 000000000..362056a81 --- /dev/null +++ b/app/react/kubernetes/ingresses/IngressDatatable/columns/created.tsx @@ -0,0 +1,23 @@ +import { CellProps, Column } from 'react-table'; + +import { formatDate } from '@/portainer/filters/filters'; + +import { Ingress } from '../../types'; + +export const created: Column = { + Header: 'Created', + id: 'created', + accessor: (row) => row.CreationDate, + Cell: ({ row }: CellProps) => { + const owner = + row.original.Labels?.['io.portainer.kubernetes.ingress.owner']; + + if (owner) { + return `${formatDate(row.original.CreationDate)} by ${owner}`; + } + + return formatDate(row.original.CreationDate); + }, + disableFilters: true, + canHide: true, +}; diff --git a/app/react/kubernetes/ingresses/IngressDatatable/columns/index.tsx b/app/react/kubernetes/ingresses/IngressDatatable/columns/index.tsx index 795b1333c..8de5fac60 100644 --- a/app/react/kubernetes/ingresses/IngressDatatable/columns/index.tsx +++ b/app/react/kubernetes/ingresses/IngressDatatable/columns/index.tsx @@ -1,11 +1,15 @@ -import { useMemo } from 'react'; - import { name } from './name'; import { type } from './type'; import { namespace } from './namespace'; import { className } from './className'; import { ingressRules } from './ingressRules'; +import { created } from './created'; -export function useColumns() { - return useMemo(() => [name, namespace, className, type, ingressRules], []); -} +export const columns = [ + name, + namespace, + className, + type, + ingressRules, + created, +]; diff --git a/app/react/kubernetes/ingresses/types.ts b/app/react/kubernetes/ingresses/types.ts index f96de587f..b064a35aa 100644 --- a/app/react/kubernetes/ingresses/types.ts +++ b/app/react/kubernetes/ingresses/types.ts @@ -33,6 +33,8 @@ export type Ingress = { Paths: Path[]; TLS?: TLS[]; Type?: string; + Labels?: Record; + CreationDate?: string; }; export interface DeleteIngressesRequest { diff --git a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx index 3f366fab6..b125cc57e 100644 --- a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx +++ b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx @@ -1,4 +1,4 @@ -import { Box, Edit, Layers, Lock, Server } from 'lucide-react'; +import { Box, Edit, Layers, Lock, Server, Shuffle } from 'lucide-react'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { Authorized } from '@/react/hooks/useUser'; @@ -70,6 +70,14 @@ export function KubernetesSidebar({ environmentId }: Props) { data-cy="k8sSidebar-applications" /> + +