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 @@ -
- - -
-
-
- -
- - {{ $ctrl.titleText }} - -
- -
- - - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - -
- - {{ 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; +}