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 @@
-
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"
/>
+
+