diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js
index 472601530..234c1c594 100644
--- a/app/kubernetes/__module.js
+++ b/app/kubernetes/__module.js
@@ -311,7 +311,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
const node = {
name: 'kubernetes.cluster.node',
- url: '/:name',
+ url: '/:nodeName',
views: {
'content@': {
component: 'kubernetesNodeView',
diff --git a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html
deleted file mode 100644
index ae41716a5..000000000
--- a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html
+++ /dev/null
@@ -1,190 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
-
-
-
-
- {{ item.Name }}
-
- api
- |
- {{ item.Role }} |
- {{ item.Status }} |
- {{ item.CPU }} |
- {{ item.Memory | humansize }} |
- {{ item.Version }} |
- {{ item.IPAddress }} |
-
- Stats
- |
-
-
- Loading... |
-
-
- No node available. |
-
-
-
-
-
-
-
-
diff --git a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js
deleted file mode 100644
index 17fc80b92..000000000
--- a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js
+++ /dev/null
@@ -1,14 +0,0 @@
-angular.module('portainer.kubernetes').component('kubernetesNodesDatatable', {
- templateUrl: './nodesDatatable.html',
- controller: 'GenericDatatableController',
- bindings: {
- titleText: '@',
- titleIcon: '@',
- dataset: '<',
- tableKey: '@',
- orderBy: '@',
- refreshCallback: '<',
- isAdmin: '<',
- useServerMetrics: '<',
- },
-});
diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts
index 2794437ff..37934f126 100644
--- a/app/kubernetes/react/components/index.ts
+++ b/app/kubernetes/react/components/index.ts
@@ -22,6 +22,7 @@ import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable';
+import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName';
export const ngModule = angular
@@ -77,6 +78,10 @@ export const ngModule = angular
'createNamespaceRegistriesSelector',
r2a(RegistriesSelector, ['inputId', 'onChange', 'options', 'value'])
)
+ .component(
+ 'kubeNodesDatatable',
+ r2a(withUIRouter(withReactQuery(withCurrentUser(NodesDatatable))), [])
+ )
.component(
'kubeApplicationAccessPolicySelector',
r2a(KubeApplicationAccessPolicySelector, [
diff --git a/app/kubernetes/views/cluster/cluster.html b/app/kubernetes/views/cluster/cluster.html
index d11d7f8c0..5661d8d36 100644
--- a/app/kubernetes/views/cluster/cluster.html
+++ b/app/kubernetes/views/cluster/cluster.html
@@ -52,16 +52,7 @@
diff --git a/app/kubernetes/views/cluster/node/nodeController.js b/app/kubernetes/views/cluster/node/nodeController.js
index e26d00f18..2c4b3daa4 100644
--- a/app/kubernetes/views/cluster/node/nodeController.js
+++ b/app/kubernetes/views/cluster/node/nodeController.js
@@ -277,7 +277,7 @@ class KubernetesNodeController {
async getNodesAsync() {
try {
this.state.dataLoading = true;
- const nodeName = this.$transition$.params().name;
+ const nodeName = this.$transition$.params().nodeName;
this.nodes = await this.KubernetesNodeService.get();
this.node = _.find(this.nodes, { Name: nodeName });
this.state.isDrainOperation = _.find(this.nodes, { Availability: this.availabilities.DRAIN });
@@ -298,7 +298,7 @@ class KubernetesNodeController {
async getNodeUsageAsync() {
try {
- const nodeName = this.$transition$.params().name;
+ const nodeName = this.$transition$.params().nodeName;
const node = await getMetricsForNode(this.$state.params.endpointId, nodeName);
this.resourceUsage = new KubernetesResourceReservation();
this.resourceUsage.CPU = KubernetesResourceReservationHelper.parseCPU(node.usage.cpu);
diff --git a/app/kubernetes/views/cluster/node/stats/stats.html b/app/kubernetes/views/cluster/node/stats/stats.html
index d53650bbf..42a14e8b5 100644
--- a/app/kubernetes/views/cluster/node/stats/stats.html
+++ b/app/kubernetes/views/cluster/node/stats/stats.html
@@ -6,7 +6,7 @@
{
label:ctrl.state.transition.nodeName,
link: 'kubernetes.cluster.node',
- linkParams:{name: ctrl.state.transition.nodeName}
+ linkParams:{nodeName: ctrl.state.transition.nodeName}
},
ctrl.state.transition.nodeName,
]"
diff --git a/app/kubernetes/views/cluster/node/stats/statsController.js b/app/kubernetes/views/cluster/node/stats/statsController.js
index 1400ff5fc..9c6184560 100644
--- a/app/kubernetes/views/cluster/node/stats/statsController.js
+++ b/app/kubernetes/views/cluster/node/stats/statsController.js
@@ -109,7 +109,7 @@ class KubernetesNodeStatsController {
refreshRate: '30',
viewReady: false,
transition: {
- nodeName: this.$transition$.params().name,
+ nodeName: this.$transition$.params().nodeName,
},
getMetrics: true,
};
diff --git a/app/react/components/StatusBadge.tsx b/app/react/components/StatusBadge.tsx
new file mode 100644
index 000000000..9f40d3ee9
--- /dev/null
+++ b/app/react/components/StatusBadge.tsx
@@ -0,0 +1,49 @@
+import clsx from 'clsx';
+import { AriaAttributes, PropsWithChildren } from 'react';
+
+import { Icon, IconProps } from '@@/Icon';
+
+export function StatusBadge({
+ className,
+ children,
+ color = 'default',
+ icon,
+ ...aria
+}: PropsWithChildren<
+ {
+ className?: string;
+ color?: 'success' | 'danger' | 'warning' | 'default';
+ icon?: IconProps['icon'];
+ } & AriaAttributes
+>) {
+ return (
+
+ {icon && (
+
+ )}
+
+ {children}
+
+ );
+}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/NodesDatatable.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/NodesDatatable.tsx
new file mode 100644
index 000000000..985332e56
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/NodesDatatable.tsx
@@ -0,0 +1,107 @@
+import { Node, Endpoints } from 'kubernetes-types/core/v1';
+import { HardDrive } from 'lucide-react';
+import { useMemo } from 'react';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
+import { IndexOptional } from '@/react/kubernetes/configs/types';
+import { useEnvironment } from '@/react/portainer/environments/queries';
+
+import { Datatable, TableSettingsMenu } from '@@/datatables';
+import { useTableState } from '@@/datatables/useTableState';
+import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
+
+import { useNodesQuery } from '../nodes.service';
+import { useKubernetesEndpointsQuery } from '../../kubernetesEndpoint.service';
+
+import { getColumns } from './columns';
+import { NodeRowData } from './types';
+import { getInternalNodeIpAddress } from './utils';
+
+const storageKey = 'k8sNodesDatatable';
+const settingsStore = createStore(storageKey);
+
+export function NodesDatatable() {
+ const tableState = useTableState(settingsStore, storageKey);
+ const environmentId = useEnvironmentId();
+ const { data: nodes, ...nodesQuery } = useNodesQuery(environmentId, {
+ autoRefreshRate: tableState.autoRefreshRate * 1000,
+ });
+ const { data: kubernetesEndpoints, ...kubernetesEndpointsQuery } =
+ useKubernetesEndpointsQuery(environmentId, {
+ autoRefreshRate: tableState.autoRefreshRate * 1000,
+ });
+ const { data: environment, ...environmentQuery } =
+ useEnvironment(environmentId);
+ const environmentUrl = environment?.URL;
+ const isServerMetricsEnabled =
+ !!environment?.Kubernetes?.Configuration.UseServerMetrics;
+ const nodeRowData = useNodeRowData(
+ nodes,
+ kubernetesEndpoints,
+ environmentUrl
+ );
+
+ return (
+ >
+ dataset={nodeRowData ?? []}
+ columns={getColumns(isServerMetricsEnabled)}
+ settingsManager={tableState}
+ isLoading={
+ nodesQuery.isLoading ||
+ kubernetesEndpointsQuery.isLoading ||
+ environmentQuery.isLoading
+ }
+ emptyContentLabel="No Nodes found"
+ title="Nodes"
+ titleIcon={HardDrive}
+ getRowId={(row) => row.metadata?.uid ?? ''}
+ renderTableSettings={() => (
+
+ tableState.setAutoRefreshRate(value)}
+ />
+
+ )}
+ />
+ );
+}
+
+/**
+ * This function is used to add the isApi property to the node row data.
+ */
+function useNodeRowData(
+ nodes?: Node[],
+ kubernetesEndpoints?: Endpoints[],
+ environmentUrl?: string
+): NodeRowData[] {
+ return useMemo(() => {
+ if (!nodes || !kubernetesEndpoints) {
+ return [];
+ }
+ const subsetAddresses = kubernetesEndpoints?.flatMap(
+ (endpoint) =>
+ endpoint.subsets?.flatMap((subset) => subset.addresses ?? [])
+ );
+ const nodeRowData = nodes.map((node) => {
+ const nodeAddress = getInternalNodeIpAddress(node);
+ // if the node address is in the endpoints subset addresses, then it is an api node
+ const isApi = subsetAddresses?.some(
+ (subsetAddress) => subsetAddress?.ip === nodeAddress
+ );
+ // if the environment url includes the node address, then it is a published node
+ const isPublishedNode =
+ !!nodeAddress &&
+ !!environmentUrl &&
+ environmentUrl?.includes(nodeAddress);
+ return {
+ ...node,
+ isApi,
+ isPublishedNode,
+ Name: `${node.metadata?.name}${isApi ? 'api' : ''}` ?? '',
+ };
+ });
+ return nodeRowData;
+ }, [nodes, kubernetesEndpoints, environmentUrl]);
+}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/actions.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/actions.tsx
new file mode 100644
index 000000000..2a940b26a
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/actions.tsx
@@ -0,0 +1,47 @@
+import { CellContext } from '@tanstack/react-table';
+import { BarChart } from 'lucide-react';
+
+import { Link } from '@@/Link';
+import { Icon } from '@@/Icon';
+
+import { NodeRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export function getActions(metricsEnabled: boolean) {
+ return columnHelper.accessor(() => '', {
+ header: 'Actions',
+ enableSorting: false,
+ cell: (props) => (
+
+ ),
+ });
+}
+
+function ActionsCell({
+ row: { original: node },
+ metricsEnabled,
+}: CellContext & {
+ metricsEnabled: boolean;
+}) {
+ const nodeName = node.metadata?.name;
+
+ return (
+
+ {metricsEnabled && (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/cpu.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/cpu.tsx
new file mode 100644
index 000000000..7fc1c92e6
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/cpu.tsx
@@ -0,0 +1,14 @@
+import { parseCpu } from '@/react/kubernetes/utils';
+
+import { NodeRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const cpu = columnHelper.accessor((row) => getCPU(row), {
+ header: 'CPU',
+ cell: ({ row: { original: node } }) => getCPU(node),
+});
+
+function getCPU(node: NodeRowData) {
+ return parseCpu(node.status?.allocatable?.cpu ?? '');
+}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/helper.ts b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/helper.ts
new file mode 100644
index 000000000..456ee2dff
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { NodeRowData } from '../types';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/index.ts b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/index.ts
new file mode 100644
index 000000000..e8cb8c8b1
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/index.ts
@@ -0,0 +1,25 @@
+import { name } from './name';
+import { role } from './role';
+import { status } from './status';
+import { cpu } from './cpu';
+import { memory } from './memory';
+import { version } from './version';
+import { ip } from './ip';
+import { getActions } from './actions';
+
+export function getColumns(isServerMetricsEnabled: boolean) {
+ if (!isServerMetricsEnabled) {
+ return [name, role, status, cpu, memory, version, ip];
+ }
+
+ return [
+ name,
+ role,
+ status,
+ cpu,
+ memory,
+ version,
+ ip,
+ getActions(isServerMetricsEnabled),
+ ];
+}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/ip.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/ip.tsx
new file mode 100644
index 000000000..9cb54eaff
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/ip.tsx
@@ -0,0 +1,11 @@
+import { getInternalNodeIpAddress } from '../utils';
+
+import { columnHelper } from './helper';
+
+export const ip = columnHelper.accessor(
+ (row) => getInternalNodeIpAddress(row) ?? '-',
+ {
+ header: 'IP Address',
+ cell: ({ row }) => getInternalNodeIpAddress(row.original) ?? '-',
+ }
+);
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/memory.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/memory.tsx
new file mode 100644
index 000000000..013058998
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/memory.tsx
@@ -0,0 +1,16 @@
+import filesizeParser from 'filesize-parser';
+
+import { humanize } from '@/portainer/filters/filters';
+
+import { NodeRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const memory = columnHelper.accessor((row) => getMemory(row), {
+ header: 'Memory',
+ cell: ({ row: { original: node } }) => getMemory(node),
+});
+
+function getMemory(node: NodeRowData) {
+ return humanize(filesizeParser(node.status?.allocatable?.memory ?? ''));
+}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/name.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/name.tsx
new file mode 100644
index 000000000..b8c4e5214
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/name.tsx
@@ -0,0 +1,35 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { Authorized } from '@/react/hooks/useUser';
+
+import { Link } from '@@/Link';
+import { Badge } from '@@/Badge';
+
+import { NodeRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const name = columnHelper.accessor('Name', {
+ header: 'Name',
+ cell: NameCell,
+});
+
+function NameCell({
+ row: { original: node },
+}: CellContext) {
+ const nodeName = node.metadata?.name;
+ return (
+
+
+
+ {nodeName}
+
+
+ {node.isApi &&
api}
+ {node.isPublishedNode &&
environment IP}
+
+ );
+}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/role.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/role.tsx
new file mode 100644
index 000000000..54e842606
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/role.tsx
@@ -0,0 +1,8 @@
+import { getRole } from '../utils';
+
+import { columnHelper } from './helper';
+
+export const role = columnHelper.accessor((row) => getRole(row), {
+ header: 'Role',
+ cell: ({ row: { original: node } }) => getRole(node),
+});
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/status.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/status.tsx
new file mode 100644
index 000000000..258c3fdb0
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/status.tsx
@@ -0,0 +1,44 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { StatusBadge } from '@@/StatusBadge';
+
+import { NodeRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const status = columnHelper.accessor((row) => getStatus(row), {
+ header: 'Status',
+ cell: StatusCell,
+});
+
+function StatusCell({
+ row: { original: node },
+}: CellContext) {
+ const status = getStatus(node);
+
+ const isDeleting =
+ node.metadata?.annotations?.['portainer.io/removing-node'] === 'true';
+ if (isDeleting) {
+ return Removing;
+ }
+
+ return (
+
+
+ {status}
+
+ {node.spec?.unschedulable && (
+
+ SchedulingDisabled
+
+ )}
+
+ );
+}
+
+function getStatus(node: NodeRowData) {
+ return (
+ node.status?.conditions?.find((condition) => condition.status === 'True')
+ ?.type ?? 'Not ready'
+ );
+}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/version.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/version.tsx
new file mode 100644
index 000000000..d0568017e
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/version.tsx
@@ -0,0 +1,10 @@
+import { columnHelper } from './helper';
+
+export const version = columnHelper.accessor(
+ (row) => row.status?.nodeInfo?.kubeletVersion ?? '',
+ {
+ header: 'Version',
+ cell: ({ row: { original: node } }) =>
+ node.status?.nodeInfo?.kubeletVersion ?? '',
+ }
+);
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/index.ts b/app/react/kubernetes/cluster/HomeView/NodesDatatable/index.ts
new file mode 100644
index 000000000..f5e6969ba
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/index.ts
@@ -0,0 +1 @@
+export { NodesDatatable } from './NodesDatatable';
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/types.ts b/app/react/kubernetes/cluster/HomeView/NodesDatatable/types.ts
new file mode 100644
index 000000000..5c7d50bb9
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/types.ts
@@ -0,0 +1,7 @@
+import { Node } from 'kubernetes-types/core/v1';
+
+export interface NodeRowData extends Node {
+ isApi: boolean;
+ isPublishedNode: boolean;
+ Name: string;
+}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts b/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts
new file mode 100644
index 000000000..965310703
--- /dev/null
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts
@@ -0,0 +1,20 @@
+import { Node } from 'kubernetes-types/core/v1';
+
+export function getInternalNodeIpAddress(node?: Node) {
+ return node?.status?.addresses?.find(
+ (address) => address.type === 'InternalIP'
+ )?.address;
+}
+
+// most kube clusters set control-plane label, older clusters set master, microk8s doesn't have either but instead sets microk8s-controlplane
+const masterLabels = [
+ 'node-role.kubernetes.io/control-plane',
+ 'node-role.kubernetes.io/master',
+ 'node.kubernetes.io/microk8s-controlplane',
+];
+
+export function getRole(node: Node): 'Control plane' | 'Worker' {
+ return masterLabels.some((label) => node.metadata?.labels?.[label])
+ ? 'Control plane'
+ : 'Worker';
+}
diff --git a/app/react/kubernetes/cluster/kubernetesEndpoint.service.ts b/app/react/kubernetes/cluster/kubernetesEndpoint.service.ts
new file mode 100644
index 000000000..b7b28a099
--- /dev/null
+++ b/app/react/kubernetes/cluster/kubernetesEndpoint.service.ts
@@ -0,0 +1,29 @@
+import { EndpointsList } from 'kubernetes-types/core/v1';
+import { useQuery } from 'react-query';
+
+import axios from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { withError } from '@/react-tools/react-query';
+
+async function getKubernetesEndpoints(environmentId: EnvironmentId) {
+ const { data: endpointsList } = await axios.get(
+ `/endpoints/${environmentId}/kubernetes/api/v1/endpoints`
+ );
+ return endpointsList.items;
+}
+
+export function useKubernetesEndpointsQuery(
+ environmentId: EnvironmentId,
+ options?: { autoRefreshRate?: number }
+) {
+ return useQuery(
+ ['environments', environmentId, 'kubernetes', 'endpoints'],
+ () => getKubernetesEndpoints(environmentId),
+ {
+ ...withError('Unable to retrieve Kubernetes endpoints'),
+ refetchInterval() {
+ return options?.autoRefreshRate ?? false;
+ },
+ }
+ );
+}
diff --git a/app/react/kubernetes/utils.ts b/app/react/kubernetes/utils.ts
new file mode 100644
index 000000000..33bc5b186
--- /dev/null
+++ b/app/react/kubernetes/utils.ts
@@ -0,0 +1,9 @@
+export function parseCpu(cpu: string) {
+ let res = parseInt(cpu, 10);
+ if (cpu.endsWith('m')) {
+ res /= 1000;
+ } else if (cpu.endsWith('n')) {
+ res /= 1000000000;
+ }
+ return res;
+}