@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
376 lines (375 loc) • 17 kB
JavaScript
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 })] }));
}