UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

443 lines (442 loc) 20.6 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; /* * Copyright 2025 The Kubernetes Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Icon } from '@iconify/react'; import Box from '@mui/material/Box'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import MenuItem from '@mui/material/MenuItem'; import { useTheme } from '@mui/material/styles'; import { MRT_FilterFns, } from 'material-react-table'; import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import { loadTableSettings, storeTableSettings } from '../../../helpers/tableSettings'; import { useSelectedClusters } from '../../../lib/k8s'; import { useFilterFunc } from '../../../lib/util'; import { DefaultHeaderAction } from '../../../redux/actionButtonsSlice'; import { useNamespaces } from '../../../redux/filterSlice'; import { HeadlampEventType, useEventCallback } from '../../../redux/headlampEventSlice'; import { useTypedSelector } from '../../../redux/hooks'; import { useSettings } from '../../App/Settings/hook'; import { ClusterGroupErrorMessage } from '../../cluster/ClusterGroupErrorMessage'; import { useLocalStorageState } from '../../globalSearch/useLocalStorageState'; import { DateLabel } from '../Label'; import Link from '../Link'; import Table from '../Table'; import { getA8RMetadata } from './A8RInfo'; import DeleteButton from './DeleteButton'; import DownloadButton from './DownloadButton'; import EditButton from './EditButton'; import ResourceTableMultiActions from './ResourceTableMultiActions'; import { RestartButton } from './RestartButton'; import ScaleButton from './ScaleButton'; import ViewButton from './ViewButton'; /** * Default column ID to use for sorting when no explicit default is provided. */ const DEFAULT_SORT_COLUMN_ID = 'age'; /** * Maximum length for generated table IDs to avoid overly long localStorage keys. */ const MAX_TABLE_ID_LENGTH = 50; export default function ResourceTable(props) { if (!!props.resourceClass) { const { resourceClass, ...otherProps } = props; return _jsx(TableFromResourceClass, { resourceClass: resourceClass, ...otherProps }); } return _jsx(ResourceTableContent, { ...props }); } function TableFromResourceClass(props) { const { resourceClass, id, ...otherProps } = props; const selectedNamespaces = useNamespaces(); const { items, errors } = resourceClass.useList({ namespace: props.namespaces ?? selectedNamespaces, }); // throttle the update of the table to once per second const throttledItems = useThrottle(items, 1000); const dispatchHeadlampEvent = useEventCallback(HeadlampEventType.LIST_VIEW); useEffect(() => { dispatchHeadlampEvent({ resources: items, resourceKind: resourceClass.className, error: errors?.[0] || undefined, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [items, errors]); return (_jsx(ResourceTableContent, { errors: errors, id: id || `headlamp-${resourceClass.pluralName}`, ...otherProps, data: throttledItems })); } /** * Here we figure out which columns are visible and not visible * We can control it using show property in the columns prop {@link ResourceTableColumn} * And when user manually changes visibility it is saved to localStorage */ function initColumnVisibilityState(columns, tableId) { const visibility = {}; // Apply default visibility we got from the props columns.forEach((col, index) => { // Labels column is hidden by default if (col === 'labels') { visibility[col] = false; } if (typeof col === 'string') return; if ('show' in col) { visibility[col.id ?? String(index)] = col.show ?? true; } }); // Load and apply persisted settings from local storage if (tableId) { const localTableSettins = loadTableSettings(tableId); localTableSettins.forEach(({ id, show }) => (visibility[id] = show)); } return visibility; } // By default MRT passes row object to the sorting function but we only need the original item function sortingFn(sortFn) { if (!sortFn) return undefined; return (a, b) => sortFn(a.original, b.original); } /** * Returns a throttled version of the input value. * * @param value - The value to be throttled. * @param interval - The interval in milliseconds to throttle the value. * @returns The throttled value. */ export function useThrottle(value, interval = 1000) { const [throttledValue, setThrottledValue] = useState(value); // eslint-disable-next-line react-hooks/purity const lastEffected = useRef(Date.now() + interval); // Ensure we don't throttle holding the loading null or undefined value before // real data comes in. Otherwise we could wait up to interval milliseconds // before we update the throttled value. // // numEffected == 0, null, or undefined whilst loading. // numEffected == 1, real data. const numEffected = useRef(0); useEffect(() => { const now = Date.now(); if (now >= lastEffected.current + interval || numEffected.current < 2) { numEffected.current = numEffected.current + 1; lastEffected.current = now; setThrottledValue(value); } else { const id = window.setTimeout(() => { lastEffected.current = now; setThrottledValue(value); }, interval); return () => window.clearTimeout(id); } }, [value, interval]); return throttledValue; } function ResourceTableContent(props) { const { columns, defaultSortingColumn, id, noProcessing = false, hideColumns = [], filterFunction, errorMessage, reflectInURL, data, defaultGlobalFilter, actions, enableRowActions = false, enableRowSelection = false, errors, } = props; const { t } = useTranslation(['glossary', 'translation']); const theme = useTheme(); const storeRowsPerPageOptions = useSettings('tableRowsPerPageOptions'); const clusters = useSelectedClusters(); const tableProcessors = useTypedSelector(state => state.resourceTable.tableColumnsProcessors); const defaultFilterFunc = useFilterFunc(); const [columnVisibility, setColumnVisibility] = useState(() => initColumnVisibilityState(columns, id)); // Generate a stable table ID for sorting state persistence // This ensures each table has unique sorting state even without explicit id const tableId = useMemo(() => { if (id) return id; // Create a stable fallback ID based on component props const columnIds = columns .map((col, index) => { if (typeof col === 'string') return col; return col.id || col.label || `col-${index}`; }) .join('-'); return `table-${columnIds.slice(0, MAX_TABLE_ID_LENGTH)}`; // Limit length to avoid overly long keys }, [id, columns]); const [sorting, setSorting] = useLocalStorageState(`table_sorting.${tableId}`, // Initial sorting state defaultSortingColumn ? // If default sorting column is provided, use that [defaultSortingColumn] : // Otherwise fallback to age column, if it exists columns.find(it => typeof it === 'string' && it === DEFAULT_SORT_COLUMN_ID) ? [{ id: DEFAULT_SORT_COLUMN_ID, desc: false }] : []); const [tableSettings] = useState(!!id ? loadTableSettings(id) : []); // Determine if any item in the current dataset carries a8r.io/owner const hasA8rOwner = useMemo(() => (data ?? []).some(item => !!item?.metadata?.annotations?.['a8r.io/owner']), [data]); const columnsWithA8rOwner = useMemo(() => { if (!hasA8rOwner) return columns; const alreadyDefined = columns.some(c => typeof c !== 'string' && c.id === 'a8r-owner'); if (alreadyDefined) return columns; const standardOrder = ['cluster', 'namespace', 'name']; let insertAt = 1; for (const sc of standardOrder) { const idx = columns.findIndex(c => c === sc); if (idx !== -1) { insertAt = idx + 1; break; } } const ownerCol = { id: 'a8r-owner', label: t('Owner'), gridTemplate: 'auto', getValue: item => item?.metadata?.annotations?.['a8r.io/owner'] ?? '-', }; return [...columns.slice(0, insertAt), ownerCol, ...columns.slice(insertAt)]; }, [columns, hasA8rOwner, t]); const [allColumns] = useMemo(() => { let processedColumns = columnsWithA8rOwner; if (!noProcessing) { tableProcessors.forEach(processorInfo => { console.debug('Processing columns with processor: ', processorInfo.id, '...'); processedColumns = processorInfo.processor({ id: id || '', columns: processedColumns }) || []; }); } function removeClusterColIfNeeded(cols) { return cols.filter(col => clusters.length > 1 || col !== 'cluster'); } const allColumns = removeClusterColIfNeeded(processedColumns) .map((col, index) => { const indexId = String(index); if (typeof col !== 'string') { const column = col; const sort = column.sort ?? true; const mrtColumn = { id: column.id ?? indexId, header: column.label, filterVariant: column.filterVariant, enableMultiSort: !!sort, enableSorting: !!sort, enableColumnFilter: !column.disableFiltering, muiTableBodyCellProps: { ...column.cellProps, // Make sure column don't override width, it'll mess up the layout // the layout is controlled only through the gridTemplate property sx: { ...(column.cellProps?.sx ?? {}), width: 'unset', minWidth: 'unset' }, }, gridTemplate: column.gridTemplate ?? 1, filterSelectOptions: column.filterSelectOptions, }; if ('getValue' in column) { mrtColumn.accessorFn = item => column.getValue?.(item) ?? ''; } else if ('getter' in column) { mrtColumn.accessorFn = column.getter; } else { mrtColumn.accessorFn = (item) => item[column.datum]; } if ('render' in column) { mrtColumn.Cell = ({ row }) => column.render?.(row.original) ?? null; } if (sort && typeof sort === 'function') { mrtColumn.sortingFn = sortingFn(sort); } return mrtColumn; } switch (col) { case 'name': return { id: 'name', header: t('translation|Name'), gridTemplate: 'auto', accessorFn: (item) => item.metadata.name, Cell: ({ row }) => row.original && _jsx(Link, { kubeObject: row.original }), }; case 'age': return { id: 'age', header: t('translation|Age'), gridTemplate: 'min-content', accessorFn: (item) => -new Date(item.metadata.creationTimestamp).getTime(), enableColumnFilter: false, muiTableBodyCellProps: { align: 'right', }, Cell: ({ row }) => row.original && (_jsx(DateLabel, { date: row.original.metadata.creationTimestamp, format: "mini", iconProps: { color: theme.palette.text.primary } })), }; case 'labels': return { id: 'labels', header: t('translation|Labels'), gridTemplate: 'min-content', accessorFn: (item) => item.metadata.labels ? Object.entries(item.metadata.labels) .map(([key, value]) => key + '=' + value) .join(', ') : '', }; case 'namespace': return { id: 'namespace', header: t('glossary|Namespace'), gridTemplate: 'auto', accessorFn: (item) => item.getNamespace() ?? '-', filterVariant: 'multi-select', Cell: ({ row }) => row.original?.getNamespace() ? (_jsx(Link, { routeName: "namespace", params: { name: row.original.getNamespace(), }, activeCluster: row.original.cluster, children: row.original.getNamespace() })) : (''), }; case 'cluster': return { id: 'cluster', header: t('glossary|Cluster'), gridTemplate: 'min-content', Cell: ({ row }) => (_jsx(Box, { sx: { whiteSpace: 'nowrap' }, children: row.original.cluster })), accessorFn: (resource) => resource.cluster, }; case 'type': case 'kind': return { id: 'kind', header: t('translation|Kind'), accessorFn: (resource) => String(resource?.kind), filterVariant: 'multi-select', gridTemplate: 'min-content', }; default: throw new Error(`Unknown column: ${col}`); } }) .filter(col => !hideColumns?.includes(col.id ?? '')); return [allColumns]; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ columnsWithA8rOwner, hideColumns, id, noProcessing, defaultSortingColumn, tableProcessors, tableSettings, sorting, ]); const defaultActions = [ { id: DefaultHeaderAction.RESTART, action: ({ item }) => _jsx(RestartButton, { item: item, buttonStyle: "menu" }, "restart"), }, { id: DefaultHeaderAction.SCALE, action: ({ item }) => _jsx(ScaleButton, { item: item, buttonStyle: "menu" }, "scale"), }, { id: DefaultHeaderAction.EDIT, action: ({ item, closeMenu }) => (_jsx(EditButton, { item: item, buttonStyle: "menu", afterConfirm: closeMenu }, "edit")), }, { id: DefaultHeaderAction.DOWNLOAD, action: ({ item }) => _jsx(DownloadButton, { item: item, buttonStyle: "menu" }, "download"), }, { id: DefaultHeaderAction.VIEW, action: ({ item }) => _jsx(ViewButton, { item: item, buttonStyle: "menu" }, "view"), }, { id: DefaultHeaderAction.DELETE, action: ({ item, closeMenu }) => (_jsx(DeleteButton, { item: item, buttonStyle: "menu", afterConfirm: closeMenu }, "delete")), }, ]; let hAccs = []; if (actions !== undefined && actions !== null) { hAccs = actions; } const a8rAction = { id: 'a8r-actions', action: ({ item, closeMenu }) => { const annotations = item?.metadata?.annotations ?? {}; const metadata = getA8RMetadata(annotations).filter(m => m.isLink); if (metadata.length === 0) return null; return (_jsx(React.Fragment, { children: metadata.map(meta => (_jsxs(MenuItem, { onClick: () => { window.open(meta.value, '_blank', 'noopener,noreferrer'); closeMenu(); }, children: [_jsx(ListItemIcon, { children: _jsx(Icon, { icon: meta.icon, width: "20" }) }), _jsx(ListItemText, { children: t(meta.labelKey) })] }, meta.key))) }, "a8r-actions")); }, }; // eslint-disable-next-line react-hooks/exhaustive-deps const actionsProcessed = [...hAccs, a8rAction, ...defaultActions]; const renderRowActionMenuItems = useMemo(() => { if (actionsProcessed.length === 0) { return undefined; } return ({ closeMenu, row }) => { return actionsProcessed.map(action => { if (action.action === undefined || action.action === null) { return _jsx(MenuItem, {}, action.id || 'empty'); } return action.action({ item: row.original, closeMenu }); }); }; }, [actionsProcessed]); const wrappedEnableRowSelection = useMemo(() => { if (import.meta.env.REACT_APP_HEADLAMP_ENABLE_ROW_SELECTION === 'false') { return false; } return enableRowSelection; }, [enableRowSelection]); const renderRowSelectionToolbar = useMemo(() => { if (!wrappedEnableRowSelection) { return undefined; } return ({ table }) => (_jsx(ResourceTableMultiActions, { table: table })); }, [wrappedEnableRowSelection]); function onColumnsVisibilityChange(updater) { setColumnVisibility(oldCols => { const newCols = typeof updater === 'function' ? updater(oldCols) : updater; if (!!id) { const colsToStore = Object.entries(newCols).map(([id, show]) => ({ id, show: (show ?? true), })); storeTableSettings(id, colsToStore); } return newCols; }); } const initialState = {}; if (defaultGlobalFilter) { initialState.globalFilter = defaultGlobalFilter; initialState.showGlobalFilter = true; } const handleSortingChange = useCallback((updaterOrValue) => { setSorting(old => { if (typeof updaterOrValue === 'function') { return updaterOrValue(old); } return updaterOrValue; }); }, [setSorting]); const filterFunc = filterFunction ?? defaultFilterFunc; return (_jsxs(_Fragment, { children: [_jsx(ClusterGroupErrorMessage, { errors: errors }), _jsx(Table, { enableFullScreenToggle: false, enableFacetedValues: true, enableRowSelection: wrappedEnableRowSelection, renderRowSelectionToolbar: renderRowSelectionToolbar, errorMessage: errorMessage, columns: allColumns, data: (data ?? []), loading: data === null, initialState: initialState, rowsPerPage: storeRowsPerPageOptions, state: { columnVisibility, sorting, }, reflectInURL: reflectInURL, onColumnVisibilityChange: onColumnsVisibilityChange, onSortingChange: handleSortingChange, enableRowActions: enableRowActions, renderRowActionMenuItems: renderRowActionMenuItems, filterFns: { kubeObjectSearch: (row, id, filterValue) => { const customFilterResult = filterFunc(row.original, filterValue); const fuzzyColumnsResult = MRT_FilterFns.contains(row, id, filterValue); return customFilterResult || fuzzyColumnsResult; }, }, globalFilterFn: "kubeObjectSearch", filterFunction: filterFunc, getRowId: item => item?.metadata?.uid })] })); }