import {CircleXmark, Pencil, TrashBin} from '@gravity-ui/icons'; import DataTable from '@gravity-ui/react-data-table'; import type {Column} from '@gravity-ui/react-data-table'; import { ClipboardButton, Link as ExternalLink, Flex, Icon, Label, Progress, Text, Tooltip, } from '@gravity-ui/uikit'; import type {DropdownMenuItemWithDescription} from '../../components/DropdownMenu'; import {DropdownMenu} from '../../components/DropdownMenu'; import {EntityStatus} from '../../components/EntityStatus/EntityStatus'; import {VersionsBar} from '../../components/VersionsBar/VersionsBar'; import type {PreparedCluster} from '../../store/reducers/clusters/types'; import type {ClusterLink} from '../../types/additionalProps'; import {EFlag} from '../../types/api/enums'; import {uiFactory} from '../../uiFactory/uiFactory'; import {CLUSTER_LINK_CONTEXT} from '../../utils/clusterLinks/clusterLinkConstants'; import {resolveClusterLinks} from '../../utils/clusterLinks/resolveClusterLinks'; import {EMPTY_DATA_PLACEHOLDER, MONITORING_UI_TITLE} from '../../utils/constants'; import {formatNumber, formatStorageValuesToTb} from '../../utils/dataFormatters/dataFormatters'; import {getCleanBalancerValue} from '../../utils/parseBalancer'; import type {PreparedVersion} from '../../utils/versions/types'; import {clusterTabsIds} from '../Cluster/utils'; import {COLUMNS_NAMES, COLUMNS_TITLES} from './constants'; import i18n from './i18n'; import {b} from './shared'; import {calculateClusterPath} from './utils'; export const CLUSTERS_COLUMNS_WIDTH_LS_KEY = 'clustersTableColumnsWidth'; const EMPTY_CELL = {EMPTY_DATA_PLACEHOLDER}; /** * Helper function to get the first version from a list of prepared versions * Strips numeric prefixes (e.g., "1.2.3." -> "2.3.") and sorts the versions * Used as sortAccessor for version columns */ export function getFirstVersion(preparedVersions: PreparedVersion[]): string | undefined { const versions = preparedVersions .map((item) => item.version.replace(/^\d+\./, '')) .sort((v1, v2) => { // Split versions into components (dots or dashes) const parts1 = v1.split(/[.-]/); const parts2 = v2.split(/[.-]/); // Compare each component const maxLength = Math.max(parts1.length, parts2.length); for (let i = 0; i < maxLength; i++) { const part1 = parts1[i] || ''; const part2 = parts2[i] || ''; // Check if both parts are purely numeric const isNum1 = /^\d+$/.test(part1); const isNum2 = /^\d+$/.test(part2); // If both are purely numeric, compare numerically if (isNum1 && isNum2) { const num1 = parseInt(part1, 10); const num2 = parseInt(part2, 10); if (num1 !== num2) { return num1 - num2; } } else { // Otherwise, compare lexicographically const comparison = part1.localeCompare(part2); if (comparison !== 0) { return comparison; } } } return 0; }); return versions[0] || undefined; } interface ClustersColumnsParams { isEditClusterAvailable?: boolean; isDeleteClusterAvailable?: boolean; onStatusClick?: (row: PreparedCluster) => void; } function getTitleColumn({ isEditClusterAvailable, isDeleteClusterAvailable, onStatusClick, }: ClustersColumnsParams) { return { name: COLUMNS_NAMES.TITLE, header: COLUMNS_TITLES[COLUMNS_NAMES.TITLE], width: 320, defaultOrder: DataTable.ASCENDING, sortAccessor: (row) => row.title || row.name, render: ({row}) => { const clusterStatus = row.cluster?.Overall; const cleanedBalancer = row.balancer ? getCleanBalancerValue(row.balancer) : null; const renderActions = () => { const menuItems: ( | DropdownMenuItemWithDescription | DropdownMenuItemWithDescription[] )[] = []; const additionalLinks: ClusterLink[] = []; if (row.monitoring) { const monitoringUrl = uiFactory.getMonitoringClusterLink?.( row.monitoring, row.name, ); if (monitoringUrl) { additionalLinks.push({ title: MONITORING_UI_TITLE, url: monitoringUrl, context: CLUSTER_LINK_CONTEXT.MONITORING, }); } } if (uiFactory.getClusterLinks) { additionalLinks.push(...uiFactory.getClusterLinks({clusterInfo: row})); } const clusterLinks = resolveClusterLinks(row, additionalLinks); if (clusterLinks.length) { menuItems.push( clusterLinks.map((link) => ({ title: link.title, description: link.description, iconStart: link.icon, href: link.url, target: link.target, })), ); } const {onEditCluster, onDeleteCluster} = uiFactory; const manageItems: DropdownMenuItemWithDescription[] = []; if (isEditClusterAvailable && onEditCluster) { manageItems.push({ title: i18n('edit-cluster'), iconStart: Pencil, action: () => { onEditCluster({clusterData: row}); }, }); } if (isDeleteClusterAvailable && onDeleteCluster) { manageItems.push({ title: i18n('remove-cluster'), iconStart: TrashBin, action: () => { onDeleteCluster({clusterData: row}); }, theme: 'danger', }); } if (manageItems.length) { menuItems.push(manageItems); } if (!menuItems.length) { return null; } return ( ); }; const renderName = () => { return ; }; const renderStatus = () => { const statusLabel = clusterStatus ? ( ) : ( ); const isHealthcheckSource = row.cluster?.overall_source === 'healthcheck'; if (!onStatusClick || !clusterStatus || !isHealthcheckSource) { return statusLabel; } return ( ); }; const renderBalancer = () => { if (!cleanedBalancer) { return null; } return ( {cleanedBalancer} ); }; return ( {renderName()} {renderStatus()} {renderActions()} {renderBalancer()} ); }, } satisfies Column; } interface ClusterNameProps { row: PreparedCluster; } function ClusterName({row}: ClusterNameProps) { const clusterPath = calculateClusterPath(row); return ( {row.title || row.name} {row.settings?.unavailable === true ? ( { if (['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) { event.stopPropagation(); } }} > ) : null} ); } const CLUSTERS_COLUMNS: Column[] = [ { name: COLUMNS_NAMES.VERSIONS, header: COLUMNS_TITLES[COLUMNS_NAMES.VERSIONS], width: 400, defaultOrder: DataTable.DESCENDING, sortAccessor: ({preparedVersions}) => getFirstVersion(preparedVersions), render: ({row}) => { return ; }, }, { name: COLUMNS_NAMES.COMPUTE_NODES_VERSIONS, header: COLUMNS_TITLES[COLUMNS_NAMES.COMPUTE_NODES_VERSIONS], width: 400, defaultOrder: DataTable.DESCENDING, sortAccessor: ({preparedComputeVersions}) => getFirstVersion(preparedComputeVersions), render: ({row}) => { return ; }, }, { name: COLUMNS_NAMES.STORAGE_NODES_VERSIONS, header: COLUMNS_TITLES[COLUMNS_NAMES.STORAGE_NODES_VERSIONS], width: 400, defaultOrder: DataTable.DESCENDING, sortAccessor: ({preparedStorageVersions}) => getFirstVersion(preparedStorageVersions), render: ({row}) => { return ; }, }, { name: COLUMNS_NAMES.STATUS, header: COLUMNS_TITLES[COLUMNS_NAMES.STATUS], width: 180, sortable: true, render: ({row}) => { return ; }, }, { name: COLUMNS_NAMES.SERVICE, header: COLUMNS_TITLES[COLUMNS_NAMES.SERVICE], width: 100, sortable: true, }, { name: COLUMNS_NAMES.DOMAIN, header: COLUMNS_TITLES[COLUMNS_NAMES.DOMAIN], width: 170, sortable: true, }, { name: COLUMNS_NAMES.GALAXY, header: COLUMNS_TITLES[COLUMNS_NAMES.GALAXY], width: 120, sortable: true, render: ({row}) => { return row.galaxy || EMPTY_CELL; }, }, { name: COLUMNS_NAMES.DC, header: COLUMNS_TITLES[COLUMNS_NAMES.DC], width: 120, sortable: false, render: ({row}) => { const dc = (row.cluster && row.cluster.DataCenters) || []; return ( // For some reason DC list could contain empty strings
{dc.filter(Boolean).join(', ') || EMPTY_CELL}
); }, }, { name: COLUMNS_NAMES.NODES, header: COLUMNS_TITLES[COLUMNS_NAMES.NODES], width: 170, resizeMinWidth: 170, defaultOrder: DataTable.DESCENDING, sortAccessor: ({cluster = {}}) => { const {NodesTotal = 0} = cluster; return NodesTotal; }, render: ({row}) => { const {NodesAlive = 0, NodesTotal = 0, Overall} = row.cluster || {}; if (!Overall) { return EMPTY_CELL; } return ( ); }, }, { name: COLUMNS_NAMES.LOAD, header: COLUMNS_TITLES[COLUMNS_NAMES.LOAD], width: 170, resizeMinWidth: 170, defaultOrder: DataTable.DESCENDING, sortAccessor: ({cluster}) => { return cluster?.NumberOfCpus || 0; }, render: ({row}) => { const { LoadAverage = 0, NumberOfCpus = 0, RealNumberOfCpus, Overall, } = row.cluster || {}; if (!Overall) { return EMPTY_CELL; } return ( ); }, }, { name: COLUMNS_NAMES.STORAGE, header: COLUMNS_TITLES[COLUMNS_NAMES.STORAGE], width: 170, resizeMinWidth: 170, defaultOrder: DataTable.DESCENDING, sortAccessor: ({cluster}) => { return Number(cluster?.StorageTotal) || 0; }, render: ({row}) => { const {StorageUsed = 0, StorageTotal = 0, Overall} = row.cluster || {}; if (!Overall) { return EMPTY_CELL; } const [valueString, capacityString] = formatStorageValuesToTb( Number(StorageUsed), Number(StorageTotal), ); return ( ); }, }, { name: COLUMNS_NAMES.HOSTS, header: COLUMNS_TITLES[COLUMNS_NAMES.HOSTS], width: 80, defaultOrder: DataTable.DESCENDING, sortAccessor: ({cluster}) => { return Number(cluster?.Hosts) || 0; }, render: ({row}) => { return Number(row.cluster?.Hosts) || EMPTY_CELL; }, }, { name: COLUMNS_NAMES.TENANTS, header: COLUMNS_TITLES[COLUMNS_NAMES.TENANTS], width: 80, defaultOrder: DataTable.DESCENDING, sortAccessor: ({cluster}) => { return Number(cluster?.Tenants) || 0; }, render: ({row}) => { return Number(row.cluster?.Tenants) || EMPTY_CELL; }, }, { name: COLUMNS_NAMES.DESCRIPTION, header: COLUMNS_TITLES[COLUMNS_NAMES.DESCRIPTION], sortable: false, width: 200, render: ({row}) => { return row.description ? (
{row.description}
) : ( EMPTY_CELL ); }, }, ]; interface VersionsProps { row: PreparedCluster; preparedVersions: PreparedVersion[]; } function Versions({row, preparedVersions}: VersionsProps) { const hasErrors = !preparedVersions.length || preparedVersions.some((item) => !item.version); if (hasErrors) { return EMPTY_CELL; } const clusterPath = calculateClusterPath(row, clusterTabsIds.versions); return ( ); } export function getClustersColumns(params: ClustersColumnsParams) { return [getTitleColumn(params), ...CLUSTERS_COLUMNS]; } function ClustersTableProgressBar({ value, capacity, description, }: { value: number; capacity: number; description?: string; }) { const usage = capacity ? (value / capacity) * 100 : 0; return (
{description}
); }