UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

376 lines (375 loc) 17 kB
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } 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 Box from '@mui/material/Box'; import MenuItem from '@mui/material/MenuItem'; import { useTheme } from '@mui/material/styles'; import { MRT_FilterFns } from 'material-react-table'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; 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/reducers/reducers'; import { useSettings } from '../../App/Settings/hook'; import { ClusterGroupErrorMessage } from '../../cluster/ClusterGroupErrorMessage'; import { DateLabel } from '../Label'; import Link from '../Link'; import Table from '../Table'; import DeleteButton from './DeleteButton'; import EditButton from './EditButton'; import ResourceTableMultiActions from './ResourceTableMultiActions'; import { RestartButton } from './RestartButton'; import ScaleButton from './ScaleButton'; import ViewButton from './ViewButton'; 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 { items, errors } = resourceClass.useList({ namespace: useNamespaces() }); // 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, }); }, [items, errors]); return (_jsx(ResourceTableContent, { errors: errors, id: id || `headlamp-${resourceClass.pluralName}`, ...otherProps, data: throttledItems })); } /** * Store the table settings in local storage. * * @param tableId - The ID of the table. * @param columns - The columns to store. * @returns void */ function storeTableSettings(tableId, columns) { if (!tableId) { console.debug('storeTableSettings: tableId is empty!', new Error().stack); return; } const columnsWithIds = columns.map((c, i) => ({ id: i.toString(), ...c })); // Delete the entry if there are no settings to store. if (columnsWithIds.length === 0) { localStorage.removeItem(`table_settings.${tableId}`); return; } localStorage.setItem(`table_settings.${tableId}`, JSON.stringify(columnsWithIds)); } /** * Load the table settings from local storage for a given table ID. * * @param tableId - The ID of the table. * @returns The table settings for the given table ID. */ function loadTableSettings(tableId) { if (!tableId) { console.debug('loadTableSettings: tableId is empty!', new Error().stack); return []; } const settings = JSON.parse(localStorage.getItem(`table_settings.${tableId}`) || '[]'); return settings; } /** * 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) => { 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); 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)); const [tableSettings] = useState(!!id ? loadTableSettings(id) : []); const [allColumns, sort] = useMemo(() => { let processedColumns = columns; 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 '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|Type'), accessorFn: (resource) => String(resource?.kind), filterVariant: 'multi-select', gridTemplate: 'min-content', }; default: throw new Error(`Unknown column: ${col}`); } }) .filter(col => !hideColumns?.includes(col.id ?? '')); let sort = undefined; const sortingColumn = defaultSortingColumn ?? allColumns.find(it => it.id === 'age'); if (sortingColumn) { sort = { id: sortingColumn.id, desc: false, }; } return [allColumns, sort]; }, [ columns, hideColumns, id, noProcessing, defaultSortingColumn, tableProcessors, tableSettings, ]); 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.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 actionsProcessed = [...hAccs, ...defaultActions]; const renderRowActionMenuItems = useMemo(() => { if (actionsProcessed.length === 0) { return null; } return ({ closeMenu, row }) => { return actionsProcessed.map(action => { if (action.action === undefined || action.action === null) { return _jsx(MenuItem, {}); } 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 = updater(oldCols); if (!!id) { const colsToStore = Object.entries(newCols).map(([id, show]) => ({ id, show: (show ?? true), })); storeTableSettings(id, colsToStore); } return newCols; }); } const initialState = { sorting: sort ? [sort] : undefined, }; if (defaultGlobalFilter) { initialState.globalFilter = defaultGlobalFilter; initialState.showGlobalFilter = true; } const filterFunc = filterFunction ?? defaultFilterFunc; return (_jsxs(_Fragment, { children: [_jsx(ClusterGroupErrorMessage, { errors: errors }), _jsx(Table, { enableFullScreenToggle: false, enableFacetedValues: true, enableRowSelection: wrappedEnableRowSelection, renderRowSelectionToolbar: renderRowSelectionToolbar, errorMessage: errorMessage, // @todo: once KubeObject is not any we can remove this casting columns: allColumns, data: (data ?? []), loading: data === null, initialState: initialState, rowsPerPage: storeRowsPerPageOptions, state: { columnVisibility, }, reflectInURL: reflectInURL, onColumnVisibilityChange: onColumnsVisibilityChange, 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 })] })); }