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