1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-21 14:29:40 +02:00

refactor(cluster): migrate nodes datatable to react [EE-4962] (#10459)

Co-authored-by: testa113 <testa113>
This commit is contained in:
Ali 2023-10-16 21:19:08 +01:00 committed by GitHub
parent b346fd7f39
commit 0e47f22c0a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 448 additions and 219 deletions

View file

@ -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 (
<Datatable<IndexOptional<NodeRowData>>
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={() => (
<TableSettingsMenu>
<TableSettingsMenuAutoRefresh
value={tableState.autoRefreshRate}
onChange={(value) => tableState.setAutoRefreshRate(value)}
/>
</TableSettingsMenu>
)}
/>
);
}
/**
* 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<NodeRowData[]>(() => {
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]);
}

View file

@ -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) => (
<ActionsCell
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
metricsEnabled={metricsEnabled}
/>
),
});
}
function ActionsCell({
row: { original: node },
metricsEnabled,
}: CellContext<NodeRowData, string> & {
metricsEnabled: boolean;
}) {
const nodeName = node.metadata?.name;
return (
<div className="flex gap-1.5">
{metricsEnabled && (
<Link
title="Stats"
to="kubernetes.cluster.node.stats"
params={{ nodeName }}
className="flex items-center p-1"
>
<Icon icon={BarChart} />
</Link>
)}
</div>
);
}

View file

@ -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 ?? '');
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { NodeRowData } from '../types';
export const columnHelper = createColumnHelper<NodeRowData>();

View file

@ -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),
];
}

View file

@ -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) ?? '-',
}
);

View file

@ -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 ?? ''));
}

View file

@ -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<NodeRowData, string>) {
const nodeName = node.metadata?.name;
return (
<div className="flex gap-2 whitespace-nowrap">
<Authorized
authorizations="K8sClusterNodeR"
childrenUnauthorized={nodeName}
>
<Link to="kubernetes.cluster.node" params={{ nodeName }}>
{nodeName}
</Link>
</Authorized>
{node.isApi && <Badge type="info">api</Badge>}
{node.isPublishedNode && <Badge type="success">environment IP</Badge>}
</div>
);
}

View file

@ -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),
});

View file

@ -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<NodeRowData, string>) {
const status = getStatus(node);
const isDeleting =
node.metadata?.annotations?.['portainer.io/removing-node'] === 'true';
if (isDeleting) {
return <StatusBadge color="warning">Removing</StatusBadge>;
}
return (
<div className="whitespace-nowrap">
<StatusBadge color={status === 'Ready' ? 'success' : 'warning'}>
{status}
</StatusBadge>
{node.spec?.unschedulable && (
<StatusBadge color="warning" className="mt-2">
SchedulingDisabled
</StatusBadge>
)}
</div>
);
}
function getStatus(node: NodeRowData) {
return (
node.status?.conditions?.find((condition) => condition.status === 'True')
?.type ?? 'Not ready'
);
}

View file

@ -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 ?? '',
}
);

View file

@ -0,0 +1 @@
export { NodesDatatable } from './NodesDatatable';

View file

@ -0,0 +1,7 @@
import { Node } from 'kubernetes-types/core/v1';
export interface NodeRowData extends Node {
isApi: boolean;
isPublishedNode: boolean;
Name: string;
}

View file

@ -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';
}