refactor(ui/datatables): migrate views to use datatable component [EE-4064] (#7609)

pull/8101/head
Chaim Lev-Ari 2022-11-22 14:16:34 +02:00 committed by GitHub
parent 0f0513c684
commit fe8e834dbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
90 changed files with 1714 additions and 2717 deletions

View File

@ -123,6 +123,10 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
endpointCount := len(endpoints)
if start < 0 {
start = 0
}
if start > endpointCount {
start = endpointCount
}

View File

@ -0,0 +1,18 @@
import { useMutation } from 'react-query';
import { activateDevice } from './open-amt.service';
export const activateDeviceMutationKey = [
'environments',
'open-amt',
'activate',
];
export function useActivateDeviceMutation() {
return useMutation(activateDevice, {
mutationKey: activateDeviceMutationKey,
meta: {
message: 'Unable to associate with OpenAMT',
},
});
}

View File

@ -40,4 +40,6 @@ export interface LicenseInfo {
nodes: number;
type: LicenseType;
valid: boolean;
enforcedAt: number;
enforced: boolean;
}

View File

@ -2,8 +2,10 @@ import { useQuery } from 'react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import { getNodesCount } from '../services/api/status.service';
import { getLicenseInfo } from './license.service';
import { LicenseInfo } from './types';
import { LicenseInfo, LicenseType } from './types';
export function useLicenseInfo() {
const { isLoading, data: info } = useQuery<LicenseInfo, Error>(
@ -18,3 +20,33 @@ export function useLicenseInfo() {
return { isLoading, info };
}
function useNodesCounts() {
const { isLoading, data } = useQuery(
['status', 'nodes'],
() => getNodesCount(),
{
onError(error) {
notifyError('Failure', error as Error, 'Failed to get nodes count');
},
}
);
return { nodesCount: data || 0, isLoading };
}
export function useIntegratedLicenseInfo() {
const { isLoading: isLoadingNodes, nodesCount } = useNodesCounts();
const { isLoading: isLoadingLicense, info } = useLicenseInfo();
if (
isLoadingLicense ||
isLoadingNodes ||
!info ||
info.type === LicenseType.Trial
) {
return null;
}
return { licenseInfo: info as LicenseInfo, usedNodes: nodesCount };
}

View File

@ -77,6 +77,11 @@
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<access-tokens-datatable
title-text="Access tokens"
title-icon="key"
@ -86,6 +91,11 @@
remove-action="removeAction"
ui-can-exit="uiCanExit"
></access-tokens-datatable>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<theme-settings></theme-settings>
</div>
</div>

View File

@ -1,190 +1,65 @@
import { useEffect } from 'react';
import {
useTable,
useSortBy,
useGlobalFilter,
usePagination,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Box, Plus, Trash2 } from 'react-feather';
import { useStore } from 'zustand';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { ContainerGroup } from '@/react/azure/types';
import { Authorized } from '@/react/hooks/useUser';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { PaginationControls } from '@@/PaginationControls';
import {
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
} from '@@/datatables';
import { multiple } from '@@/datatables/filter-types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { useRowSelect } from '@@/datatables/useRowSelect';
import { Checkbox } from '@@/form-components/Checkbox';
import { TableFooter } from '@@/datatables/TableFooter';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { TableSettings } from './types';
import { useColumns } from './columns';
import { columns } from './columns';
const tableKey = 'containergroups';
const settingsStore = createPersistedStore(tableKey, 'name');
export interface Props {
tableKey: string;
dataset: ContainerGroup[];
onRemoveClick(containerIds: string[]): void;
}
export function ContainersDatatable({
dataset,
tableKey,
onRemoveClick,
}: Props) {
const { settings, setTableSettings } = useTableSettings<TableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey);
const columns = useColumns();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<ContainerGroup>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
selectCheckboxComponent: Checkbox,
autoResetSelectedRows: false,
getRowId(row) {
return row.id;
},
},
useGlobalFilter,
useSortBy,
usePagination,
useRowSelect,
useRowSelectColumn
);
const debouncedSearchValue = useDebouncedValue(searchBarValue);
useEffect(() => {
setGlobalFilter(debouncedSearchValue);
}, [debouncedSearchValue, setGlobalFilter]);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
export function ContainersDatatable({ dataset, onRemoveClick }: Props) {
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(tableKey);
return (
<div className="row">
<div className="col-sm-12">
<TableContainer>
<TableTitle icon={Box} label="Containers">
<SearchBar
value={searchBarValue}
onChange={handleSearchBarChange}
/>
<TableActions>
<Authorized authorizations="AzureContainerGroupDelete">
<Button
color="dangerlight"
disabled={selectedFlatRows.length === 0}
onClick={() =>
handleRemoveClick(
selectedFlatRows.map((row) => row.original.id)
)
}
icon={Trash2}
>
Remove
</Button>
</Authorized>
<Authorized authorizations="AzureContainerGroupCreate">
<Link to="azure.containerinstances.new" className="space-left">
<Button icon={Plus}>Add container</Button>
</Link>
</Authorized>
</TableActions>
</TableTitle>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<ContainerGroup>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
<Datatable
dataset={dataset}
columns={columns}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
title="Containers"
titleIcon={Box}
getRowId={(container) => container.id}
emptyContentLabel="No container available."
renderTableActions={(selectedRows) => (
<>
<Authorized authorizations="AzureContainerGroupDelete">
<Button
color="dangerlight"
disabled={selectedRows.length === 0}
onClick={() => handleRemoveClick(selectedRows.map((r) => r.id))}
icon={Trash2}
>
<Table.Content
prepareRow={prepareRow}
renderRow={(row, { key, className, role, style }) => (
<TableRow<ContainerGroup>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
rows={page}
emptyContent="No container available."
/>
</tbody>
</Table>
Remove
</Button>
</Authorized>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={dataset.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
</div>
</div>
<Authorized authorizations="AzureContainerGroupCreate">
<Link to="azure.containerinstances.new" className="space-left">
<Button icon={Plus}>Add container</Button>
</Link>
</Authorized>
</>
)}
/>
);
async function handleRemoveClick(containerIds: string[]) {
@ -197,20 +72,4 @@ export function ContainersDatatable({
return onRemoveClick(containerIds);
}
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
}

View File

@ -9,19 +9,10 @@ import { useContainerGroups } from '@/react/azure/queries/useContainerGroups';
import { useSubscriptions } from '@/react/azure/queries/useSubscriptions';
import { PageHeader } from '@@/PageHeader';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { ContainersDatatable } from './ContainersDatatable';
import { TableSettings } from './types';
export function ListView() {
const defaultSettings: TableSettings = {
pageSize: 10,
sortBy: { id: 'state', desc: false },
};
const tableKey = 'containergroups';
const environmentId = useEnvironmentId();
const subscriptionsQuery = useSubscriptions(environmentId);
@ -45,13 +36,11 @@ export function ListView() {
reload
title="Container list"
/>
<TableSettingsProvider defaults={defaultSettings} storageKey={tableKey}>
<ContainersDatatable
tableKey={tableKey}
dataset={groupsQuery.containerGroups}
onRemoveClick={handleRemove}
/>
</TableSettingsProvider>
<ContainersDatatable
dataset={groupsQuery.containerGroups}
onRemoveClick={handleRemove}
/>
</>
);
}

View File

@ -1,10 +1,6 @@
import { useMemo } from 'react';
import { name } from './name';
import { location } from './location';
import { ports } from './ports';
import { ownership } from './ownership';
export function useColumns() {
return useMemo(() => [name, location, ports, ownership], []);
}
export const columns = [name, location, ports, ownership];

View File

@ -1,8 +0,0 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
export interface TableSettings
extends PaginationTableSettings,
SortableTableSettings {}

View File

@ -8,79 +8,95 @@ import {
Row,
TableInstance,
TableState,
TableRowProps,
useExpanded,
} from 'react-table';
import { ReactNode, useEffect } from 'react';
import { ReactNode } from 'react';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import clsx from 'clsx';
import { PaginationControls } from '@@/PaginationControls';
import { IconProps } from '@@/Icon';
import { Table } from './Table';
import { multiple } from './filter-types';
import { SearchBar, useSearchBarState } from './SearchBar';
import { SelectedRowsCount } from './SelectedRowsCount';
import { TableSettingsProvider } from './useZustandTableSettings';
import { useRowSelect } from './useRowSelect';
import { PaginationTableSettings, SortableTableSettings } from './types';
import { BasicTableSettings } from './types';
import { DatatableHeader } from './DatatableHeader';
import { DatatableFooter } from './DatatableFooter';
import { DatatableContent } from './DatatableContent';
import { defaultGetRowId } from './defaultGetRowId';
import { emptyPlugin } from './emptyReactTablePlugin';
import { useGoToHighlightedRow } from './useGoToHighlightedRow';
interface DefaultTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
interface TitleOptionsVisible {
title: string;
icon?: IconProps['icon'];
featherIcon?: IconProps['featherIcon'];
hide?: never;
}
type TitleOptions = TitleOptionsVisible | { hide: true };
interface Props<
D extends Record<string, unknown>,
TSettings extends DefaultTableSettings
> {
export interface Props<D extends Record<string, unknown>> {
dataset: D[];
storageKey: string;
columns: readonly Column<D>[];
renderTableSettings?(instance: TableInstance<D>): ReactNode;
renderTableActions?(selectedRows: D[]): ReactNode;
settingsStore: TSettings;
disableSelect?: boolean;
getRowId?(row: D): string;
isRowSelectable?(row: Row<D>): boolean;
emptyContentLabel?: string;
titleOptions: TitleOptions;
title?: string;
titleIcon?: IconProps['icon'];
initialTableState?: Partial<TableState<D>>;
isLoading?: boolean;
totalCount?: number;
description?: JSX.Element;
initialActiveItem?: string;
description?: ReactNode;
pageCount?: number;
initialSortBy?: BasicTableSettings['sortBy'];
initialPageSize?: BasicTableSettings['pageSize'];
highlightedItemId?: string;
searchValue: string;
onSearchChange(search: string): void;
onSortByChange(colId: string, desc: boolean): void;
onPageSizeChange(pageSize: number): void;
// send state up
onPageChange?(page: number): void;
renderRow?(
row: Row<D>,
rowProps: TableRowProps,
highlightedItemId?: string
): ReactNode;
expandable?: boolean;
noWidget?: boolean;
}
export function Datatable<
D extends Record<string, unknown>,
TSettings extends DefaultTableSettings
>({
export function Datatable<D extends Record<string, unknown>>({
columns,
dataset,
storageKey,
renderTableSettings,
renderTableActions,
settingsStore,
renderTableSettings = () => null,
renderTableActions = () => null,
disableSelect,
getRowId = defaultGetRowId,
isRowSelectable = () => true,
titleOptions,
title,
titleIcon,
emptyContentLabel,
initialTableState = {},
isLoading,
totalCount = dataset.length,
description,
initialActiveItem,
}: Props<D, TSettings>) {
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
pageCount,
initialSortBy,
initialPageSize = 10,
onPageChange = () => {},
onPageSizeChange,
onSortByChange,
searchValue,
onSearchChange,
renderRow = defaultRenderRow,
expandable = false,
highlightedItemId,
noWidget,
}: Props<D>) {
const isServerSidePagination = typeof pageCount !== 'undefined';
const tableInstance = useTable<D>(
{
@ -89,183 +105,104 @@ export function Datatable<
data: dataset,
filterTypes: { multiple },
initialState: {
pageSize: settingsStore.pageSize || 10,
sortBy: [settingsStore.sortBy],
globalFilter: searchBarValue,
pageSize: initialPageSize,
sortBy: initialSortBy ? [initialSortBy] : [],
globalFilter: searchValue,
...initialTableState,
},
isRowSelectable,
autoResetExpanded: false,
autoResetSelectedRows: false,
getRowId,
stateReducer: (newState, action) => {
switch (action.type) {
case 'setGlobalFilter':
setSearchBarValue(action.filterValue);
break;
case 'toggleSortBy':
settingsStore.setSortBy(action.columnId, action.desc);
break;
case 'setPageSize':
settingsStore.setPageSize(action.pageSize);
break;
default:
break;
}
return newState;
},
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
},
useFilters,
useGlobalFilter,
useSortBy,
expandable ? useExpanded : emptyPlugin,
usePagination,
useRowSelect,
!disableSelect ? useRowSelectColumn : emptyPlugin
);
const {
rows,
selectedFlatRows,
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = tableInstance;
useGoToHighlightedRow(
isServerSidePagination,
tableInstance.state.pageSize,
tableInstance.rows,
handlePageChange,
highlightedItemId
);
useEffect(() => {
if (initialActiveItem && pageSize !== rows.length) {
const paginatedData = [...Array(Math.ceil(rows.length / pageSize))].map(
(_, i) => rows.slice(pageSize * i, pageSize + pageSize * i)
);
const itemPage = paginatedData.findIndex((sub) =>
sub.some((row) => row.id === initialActiveItem)
);
gotoPage(itemPage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialActiveItem]);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const selectedItems = selectedFlatRows.map((row) => row.original);
const selectedItems = tableInstance.selectedFlatRows.map(
(row) => row.original
);
return (
<div className="row">
<div className="col-sm-12">
<TableSettingsProvider settings={settingsStore}>
<Table.Container>
{isTitleVisible(titleOptions) && (
<Table.Title
label={titleOptions.title}
icon={titleOptions.icon}
featherIcon={titleOptions.featherIcon}
description={description}
>
<SearchBar value={searchBarValue} onChange={setGlobalFilter} />
{renderTableActions && (
<Table.Actions>
{renderTableActions(selectedItems)}
</Table.Actions>
)}
<Table.TitleActions>
{!!renderTableSettings && renderTableSettings(tableInstance)}
</Table.TitleActions>
</Table.Title>
)}
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<Table.HeaderRow<D>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content<D>
rows={page}
isLoading={isLoading}
prepareRow={prepareRow}
emptyContent={emptyContentLabel}
renderRow={(row, { key, className, role, style }) => (
<Table.Row<D>
cells={row.cells}
key={key}
className={clsx(
className,
initialActiveItem &&
initialActiveItem === row.id &&
'active'
)}
role={role}
style={style}
/>
)}
/>
</tbody>
</Table>
<Table.Footer>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={totalCount}
onPageLimitChange={setPageSize}
/>
</Table.Footer>
</Table.Container>
</TableSettingsProvider>
</div>
</div>
<Table.Container noWidget={noWidget}>
<DatatableHeader
onSearchChange={handleSearchBarChange}
searchValue={searchValue}
title={title}
titleIcon={titleIcon}
renderTableActions={() => renderTableActions(selectedItems)}
renderTableSettings={() => renderTableSettings(tableInstance)}
description={description}
/>
<DatatableContent<D>
tableInstance={tableInstance}
renderRow={(row, rowProps) =>
renderRow(row, rowProps, highlightedItemId)
}
emptyContentLabel={emptyContentLabel}
isLoading={isLoading}
onSortChange={handleSortChange}
/>
<DatatableFooter
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
page={tableInstance.state.pageIndex}
pageSize={tableInstance.state.pageSize}
totalCount={totalCount}
totalSelected={selectedItems.length}
/>
</Table.Container>
);
function handleSearchBarChange(value: string) {
tableInstance.setGlobalFilter(value);
onSearchChange(value);
}
function handlePageChange(page: number) {
tableInstance.gotoPage(page);
onPageChange(page);
}
function handleSortChange(colId: string, desc: boolean) {
onSortByChange(colId, desc);
}
function handlePageSizeChange(pageSize: number) {
tableInstance.setPageSize(pageSize);
onPageSizeChange(pageSize);
}
}
function defaultRenderRow<D extends Record<string, unknown>>(
row: Row<D>,
rowProps: TableRowProps,
highlightedItemId?: string
) {
return (
<Table.Row<D>
key={rowProps.key}
cells={row.cells}
className={clsx(rowProps.className, {
active: highlightedItemId === row.id,
})}
role={rowProps.role}
style={rowProps.style}
/>
);
}
function isTitleVisible(
titleSettings: TitleOptions
): titleSettings is TitleOptionsVisible {
return !titleSettings.hide;
}
function defaultGetRowId<D extends Record<string, unknown>>(row: D): string {
if (row.id && (typeof row.id === 'string' || typeof row.id === 'number')) {
return row.id.toString();
}
if (row.Id && (typeof row.Id === 'string' || typeof row.Id === 'number')) {
return row.Id.toString();
}
if (row.ID && (typeof row.ID === 'string' || typeof row.ID === 'number')) {
return row.ID.toString();
}
return '';
}
function emptyPlugin() {}
emptyPlugin.pluginName = 'emptyPlugin';

View File

@ -0,0 +1,62 @@
import { Row, TableInstance, TableRowProps } from 'react-table';
import { Table } from './Table';
interface Props<D extends Record<string, unknown>> {
tableInstance: TableInstance<D>;
renderRow(row: Row<D>, rowProps: TableRowProps): React.ReactNode;
onSortChange?(colId: string, desc: boolean): void;
isLoading?: boolean;
emptyContentLabel?: string;
}
export function DatatableContent<D extends Record<string, unknown>>({
tableInstance,
renderRow,
onSortChange,
isLoading,
emptyContentLabel,
}: Props<D>) {
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
tableInstance;
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return (
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<Table.HeaderRow<D>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={onSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content<D>
rows={page}
isLoading={isLoading}
prepareRow={prepareRow}
emptyContent={emptyContentLabel}
renderRow={renderRow}
/>
</tbody>
</Table>
);
}

View File

@ -0,0 +1,36 @@
import { PaginationControls } from '@@/PaginationControls';
import { Table } from './Table';
import { SelectedRowsCount } from './SelectedRowsCount';
interface Props {
totalSelected: number;
pageSize: number;
page: number;
onPageChange(page: number): void;
totalCount: number;
onPageSizeChange(pageSize: number): void;
}
export function DatatableFooter({
totalSelected,
pageSize,
page,
onPageChange,
totalCount,
onPageSizeChange,
}: Props) {
return (
<Table.Footer>
<SelectedRowsCount value={totalSelected} />
<PaginationControls
showAll
pageLimit={pageSize}
page={page + 1}
onPageChange={(page) => onPageChange(page - 1)}
totalCount={totalCount}
onPageLimitChange={onPageSizeChange}
/>
</Table.Footer>
);
}

View File

@ -0,0 +1,42 @@
import { ReactNode } from 'react';
import { IconProps } from '@@/Icon';
import { SearchBar } from './SearchBar';
import { Table } from './Table';
type Props = {
title?: string;
titleIcon?: IconProps['icon'];
searchValue: string;
onSearchChange(value: string): void;
renderTableSettings?(): ReactNode;
renderTableActions?(): ReactNode;
description?: ReactNode;
};
export function DatatableHeader({
onSearchChange,
renderTableActions,
renderTableSettings,
searchValue,
title,
titleIcon,
description,
}: Props) {
if (!title) {
return null;
}
return (
<Table.Title label={title} icon={titleIcon} description={description}>
<SearchBar value={searchValue} onChange={onSearchChange} />
{renderTableActions && (
<Table.Actions>{renderTableActions()}</Table.Actions>
)}
<Table.TitleActions>
{!!renderTableSettings && renderTableSettings()}
</Table.TitleActions>
</Table.Title>
);
}

View File

@ -0,0 +1,33 @@
import { Row } from 'react-table';
import { ReactNode } from 'react';
import { ExpandableDatatableTableRow } from './ExpandableDatatableRow';
import { Datatable, Props as DatatableProps } from './Datatable';
interface Props<D extends Record<string, unknown>>
extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> {
renderSubRow(row: Row<D>): ReactNode;
}
export function ExpandableDatatable<D extends Record<string, unknown>>({
renderSubRow,
...props
}: Props<D>) {
return (
<Datatable<D>
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
expandable
renderRow={(row, { key, className, role, style }) => (
<ExpandableDatatableTableRow<D>
key={key}
row={row}
className={className}
role={role}
style={style}
renderSubRow={renderSubRow}
/>
)}
/>
);
}

View File

@ -0,0 +1,41 @@
import { CSSProperties, ReactNode } from 'react';
import { Row } from 'react-table';
import { TableRow } from './TableRow';
interface Props<D extends Record<string, unknown>> {
row: Row<D>;
className?: string;
role?: string;
style?: CSSProperties;
disableSelect?: boolean;
renderSubRow(row: Row<D>): ReactNode;
}
export function ExpandableDatatableTableRow<D extends Record<string, unknown>>({
row,
className,
role,
style,
disableSelect,
renderSubRow,
}: Props<D>) {
return (
<>
<TableRow<D>
cells={row.cells}
className={className}
role={role}
style={style}
/>
{row.isExpanded && (
<tr>
{!disableSelect && <td />}
<td colSpan={disableSelect ? row.cells.length : row.cells.length - 1}>
{renderSubRow(row)}
</td>
</tr>
)}
</>
);
}

View File

@ -0,0 +1,74 @@
import {
useTable,
useFilters,
useSortBy,
Column,
TableState,
usePagination,
} from 'react-table';
import { Table } from './Table';
import { multiple } from './filter-types';
import { NestedTable } from './NestedTable';
import { DatatableContent } from './DatatableContent';
import { defaultGetRowId } from './defaultGetRowId';
interface Props<D extends Record<string, unknown>> {
dataset: D[];
columns: readonly Column<D>[];
getRowId?(row: D): string;
emptyContentLabel?: string;
initialTableState?: Partial<TableState<D>>;
isLoading?: boolean;
defaultSortBy?: string;
}
export function NestedDatatable<D extends Record<string, unknown>>({
columns,
dataset,
getRowId = defaultGetRowId,
emptyContentLabel,
initialTableState = {},
isLoading,
defaultSortBy,
}: Props<D>) {
const tableInstance = useTable<D>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
sortBy: defaultSortBy ? [{ id: defaultSortBy, desc: true }] : [],
...initialTableState,
},
autoResetSelectedRows: false,
getRowId,
},
useFilters,
useSortBy,
usePagination
);
return (
<NestedTable>
<Table.Container>
<DatatableContent<D>
tableInstance={tableInstance}
isLoading={isLoading}
emptyContentLabel={emptyContentLabel}
renderRow={(row, { key, className, role, style }) => (
<Table.Row<D>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
/>
</Table.Container>
</NestedTable>
);
}

View File

@ -1,7 +1,7 @@
import { PropsWithChildren } from 'react';
import './InnerDatatable.css';
import './NestedTable.css';
export function InnerDatatable({ children }: PropsWithChildren<unknown>) {
export function NestedTable({ children }: PropsWithChildren<unknown>) {
return <div className="inner-datatable">{children}</div>;
}

View File

@ -5,7 +5,7 @@ import {
import { Checkbox } from '@@/form-components/Checkbox';
import { useTableSettings } from './useZustandTableSettings';
import { useTableSettings } from './useTableSettings';
export interface Action {
id: QuickAction;
@ -17,7 +17,7 @@ interface Props {
}
export function QuickActionsSettings({ actions }: Props) {
const { settings } =
const settings =
useTableSettings<SettableQuickActionsTableSettings<QuickAction>>();
return (

View File

@ -35,6 +35,8 @@ function MainComponent({
);
}
MainComponent.displayName = 'Table';
interface SubComponents {
Container: typeof TableContainer;
Actions: typeof TableActions;

View File

@ -2,12 +2,28 @@ import { PropsWithChildren } from 'react';
import { Widget, WidgetBody } from '@@/Widget';
export function TableContainer({ children }: PropsWithChildren<unknown>) {
interface Props {
// workaround to remove the widget, ideally we should have a different component to wrap the table with a widget
noWidget?: boolean;
}
export function TableContainer({
children,
noWidget = false,
}: PropsWithChildren<Props>) {
if (noWidget) {
return <div className="datatable">{children}</div>;
}
return (
<div className="datatable">
<Widget>
<WidgetBody className="no-padding">{children}</WidgetBody>
</Widget>
<div className="row">
<div className="col-sm-12">
<div className="datatable">
<Widget>
<WidgetBody className="no-padding">{children}</WidgetBody>
</Widget>
</div>
</div>
</div>
);
}

View File

@ -6,7 +6,7 @@ interface Props {
icon?: ReactNode | ComponentType<unknown>;
featherIcon?: boolean;
label: string;
description?: JSX.Element;
description?: ReactNode;
}
export function TableTitle({
@ -34,7 +34,7 @@ export function TableTitle({
</div>
{children}
</div>
{description && description}
{description}
</div>
);
}

View File

@ -0,0 +1,17 @@
export function defaultGetRowId<D extends Record<string, unknown>>(
row: D
): string {
if (row.id && (typeof row.id === 'string' || typeof row.id === 'number')) {
return row.id.toString();
}
if (row.Id && (typeof row.Id === 'string' || typeof row.Id === 'number')) {
return row.Id.toString();
}
if (row.ID && (typeof row.ID === 'string' || typeof row.ID === 'number')) {
return row.ID.toString();
}
return '';
}

View File

@ -0,0 +1,3 @@
export function emptyPlugin() {}
emptyPlugin.pluginName = 'emptyPlugin';

View File

@ -0,0 +1,49 @@
import { ChevronDown, ChevronUp } from 'react-feather';
import { CellProps, Column, HeaderProps } from 'react-table';
import { Button } from '@@/buttons';
export function buildExpandColumn<T extends Record<string, unknown>>(
isExpandable: (item: T) => boolean
): Column<T> {
return {
id: 'expand',
Header: ({
filteredFlatRows,
getToggleAllRowsExpandedProps,
isAllRowsExpanded,
}: HeaderProps<T>) => {
const hasExpandableItems = filteredFlatRows.some((item) =>
isExpandable(item.original)
);
return (
hasExpandableItems && (
<Button
// eslint-disable-next-line react/jsx-props-no-spreading
{...getToggleAllRowsExpandedProps()}
color="none"
icon={isAllRowsExpanded ? ChevronDown : ChevronUp}
/>
)
);
},
Cell: ({ row }: CellProps<T>) => (
<div className="vertical-center">
{isExpandable(row.original) && (
<Button
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...row.getToggleRowExpandedProps()}
color="none"
icon={row.isExpanded ? ChevronDown : ChevronUp}
/>
)}
</div>
),
disableFilters: true,
Filter: () => null,
canHide: false,
width: 30,
disableResizing: true,
};
}

View File

@ -1,15 +0,0 @@
export interface PaginationTableSettings {
pageSize: number;
}
export interface SortableTableSettings {
sortBy: { id: string; desc: boolean };
}
export interface SettableColumnsTableSettings {
hiddenColumns: string[];
}
export interface RefreshableTableSettings {
autoRefreshRate: number;
}

View File

@ -1,15 +1,20 @@
import { createStore } from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
export interface PaginationTableSettings {
pageSize: number;
setPageSize: (pageSize: number) => void;
}
type Set<T> = (
type ZustandSetFunc<T> = (
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
replace?: boolean | undefined
) => void;
export function paginationSettings(
set: Set<PaginationTableSettings>
set: ZustandSetFunc<PaginationTableSettings>
): PaginationTableSettings {
return {
pageSize: 10,
@ -23,12 +28,14 @@ export interface SortableTableSettings {
}
export function sortableSettings(
set: Set<SortableTableSettings>,
initialSortBy = 'name',
desc = false
set: ZustandSetFunc<SortableTableSettings>,
initialSortBy: string | { id: string; desc: boolean }
): SortableTableSettings {
return {
sortBy: { id: initialSortBy, desc },
sortBy:
typeof initialSortBy === 'string'
? { id: initialSortBy, desc: false }
: initialSortBy,
setSortBy: (id: string, desc: boolean) => set({ sortBy: { id, desc } }),
};
}
@ -39,7 +46,7 @@ export interface SettableColumnsTableSettings {
}
export function hiddenColumnsSettings(
set: Set<SettableColumnsTableSettings>
set: ZustandSetFunc<SettableColumnsTableSettings>
): SettableColumnsTableSettings {
return {
hiddenColumns: [],
@ -53,10 +60,38 @@ export interface RefreshableTableSettings {
}
export function refreshableSettings(
set: Set<RefreshableTableSettings>
set: ZustandSetFunc<RefreshableTableSettings>
): RefreshableTableSettings {
return {
autoRefreshRate: 0,
setAutoRefreshRate: (autoRefreshRate: number) => set({ autoRefreshRate }),
};
}
export interface BasicTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
export function createPersistedStore<T extends BasicTableSettings>(
storageKey: string,
initialSortBy: string | { id: string; desc: boolean } = 'name',
create: (set: ZustandSetFunc<T>) => Omit<T, keyof BasicTableSettings> = () =>
({} as T)
) {
return createStore<T>()(
persist(
(set) =>
({
...sortableSettings(
set as ZustandSetFunc<SortableTableSettings>,
initialSortBy
),
...paginationSettings(set as ZustandSetFunc<PaginationTableSettings>),
...create(set),
} as T),
{
name: keyBuilder(storageKey),
}
)
);
}

View File

@ -0,0 +1,51 @@
import _ from 'lodash';
import { useRef, useLayoutEffect, useEffect } from 'react';
export function useGoToHighlightedRow<T extends { id: string }>(
isServerSidePagination: boolean,
pageSize: number,
rows: Array<T>,
goToPage: (page: number) => void,
highlightedItemId?: string
) {
const handlePageChangeRef = useRef(goToPage);
useLayoutEffect(() => {
handlePageChangeRef.current = goToPage;
});
const highlightedItemIdRef = useRef(highlightedItemId);
useEffect(() => {
if (
!isServerSidePagination &&
highlightedItemId &&
highlightedItemId !== highlightedItemIdRef.current
) {
const page = getRowPage(highlightedItemId, pageSize, rows);
if (page) {
handlePageChangeRef.current(page);
}
highlightedItemIdRef.current = highlightedItemId;
}
}, [highlightedItemId, isServerSidePagination, rows, pageSize]);
}
function getRowPage<T extends { id: string }>(
rowID: string,
pageSize: number,
rows: Array<T>
) {
const totalRows = rows.length;
if (!rowID || pageSize > totalRows) {
return 0;
}
const paginatedData = _.chunk(rows, pageSize);
const itemPage = paginatedData.findIndex((sub) =>
sub.some((row) => row.id === rowID)
);
return itemPage;
}

View File

@ -1,91 +1,34 @@
import {
Context,
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { Context, createContext, ReactNode, useContext } from 'react';
import { StoreApi, useStore } from 'zustand';
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
interface TableSettingsContextInterface<T> {
settings: T;
setTableSettings(partialSettings: Partial<T>): void;
setTableSettings(mutation: (settings: T) => T): void;
}
const TableSettingsContext = createContext<TableSettingsContextInterface<
Record<string, unknown>
> | null>(null);
const TableSettingsContext = createContext<StoreApi<object> | null>(null);
TableSettingsContext.displayName = 'TableSettingsContext';
export function useTableSettings<T>() {
export function useTableSettings<T extends object>() {
const Context = getContextType<T>();
const context = useContext(Context);
if (context === null) {
throw new Error('must be nested under TableSettingsProvider');
}
return context;
return useStore(context);
}
interface ProviderProps<T> {
interface ProviderProps<T extends object> {
children: ReactNode;
defaults?: T;
storageKey: string;
settings: StoreApi<T>;
}
export function TableSettingsProvider<T>({
export function TableSettingsProvider<T extends object>({
children,
defaults,
storageKey,
settings,
}: ProviderProps<T>) {
const Context = getContextType<T>();
const [storage, setStorage] = useLocalStorage<T>(
keyBuilder(storageKey),
defaults as T
);
const [settings, setTableSettings] = useState(storage);
const handleChange = useCallback(
(mutation: Partial<T> | ((settings: T) => T)): void => {
setTableSettings((settings) => {
const newTableSettings =
mutation instanceof Function
? mutation(settings)
: { ...settings, ...mutation };
setStorage(newTableSettings);
return newTableSettings;
});
},
[setStorage]
);
const contextValue = useMemo(
() => ({
settings,
setTableSettings: handleChange,
}),
[settings, handleChange]
);
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
function keyBuilder(key: string) {
return `datatable_TableSettings_${key}`;
}
return <Context.Provider value={settings}>{children}</Context.Provider>;
}
function getContextType<T>() {
return TableSettingsContext as unknown as Context<
TableSettingsContextInterface<T>
>;
function getContextType<T extends object>() {
return TableSettingsContext as unknown as Context<StoreApi<T>>;
}

View File

@ -1,49 +0,0 @@
import { Context, createContext, ReactNode, useContext, useMemo } from 'react';
interface TableSettingsContextInterface<T> {
settings: T;
}
const TableSettingsContext = createContext<TableSettingsContextInterface<
Record<string, unknown>
> | null>(null);
TableSettingsContext.displayName = 'TableSettingsContext';
export function useTableSettings<T>() {
const Context = getContextType<T>();
const context = useContext(Context);
if (context === null) {
throw new Error('must be nested under TableSettingsProvider');
}
return context;
}
interface ProviderProps<T> {
children: ReactNode;
settings: T;
}
export function TableSettingsProvider<T>({
children,
settings,
}: ProviderProps<T>) {
const Context = getContextType<T>();
const contextValue = useMemo(
() => ({
settings,
}),
[settings]
);
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
function getContextType<T>() {
return TableSettingsContext as unknown as Context<
TableSettingsContextInterface<T>
>;
}

View File

@ -1,4 +1,6 @@
import _ from 'lodash';
import { useStore } from 'zustand';
import { Box } from 'react-feather';
import { Environment } from '@/react/portainer/environments/types';
import type { DockerContainer } from '@/react/docker/containers/types';
@ -10,6 +12,8 @@ import {
QuickActionsSettings,
} from '@@/datatables/QuickActionsSettings';
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { useContainers } from '../../queries/containers';
@ -20,7 +24,7 @@ import { ContainersDatatableActions } from './ContainersDatatableActions';
import { RowProvider } from './RowContext';
const storageKey = 'containers';
const useStore = createStore(storageKey);
const settingsStore = createStore(storageKey);
const actions = [
buildAction('logs', 'Logs'),
@ -39,13 +43,15 @@ export function ContainersDatatable({
isHostColumnVisible,
environment,
}: Props) {
const settings = useStore();
const settings = useStore(settingsStore);
const isGPUsColumnVisible = useShowGPUsColumn(environment.Id);
const columns = useColumns(isHostColumnVisible, isGPUsColumnVisible);
const hidableColumns = _.compact(
columns.filter((col) => col.canHide).map((col) => col.id)
);
const [search, setSearch] = useSearchBarState(storageKey);
const containersQuery = useContainers(
environment.Id,
true,
@ -55,53 +61,57 @@ export function ContainersDatatable({
return (
<RowProvider context={{ environment }}>
<Datatable
titleOptions={{
icon: 'svg-cubes',
title: 'Containers',
}}
settingsStore={settings}
columns={columns}
renderTableActions={(selectedRows) => (
<ContainersDatatableActions
selectedItems={selectedRows}
isAddActionVisible
endpointId={environment.Id}
/>
)}
isLoading={containersQuery.isLoading}
isRowSelectable={(row) => !row.original.IsPortainer}
initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter((colInstance) =>
hidableColumns?.includes(colInstance.id)
);
<TableSettingsProvider settings={settingsStore}>
<Datatable
titleIcon={Box}
title="Containers"
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
columns={columns}
renderTableActions={(selectedRows) => (
<ContainersDatatableActions
selectedItems={selectedRows}
isAddActionVisible
endpointId={environment.Id}
/>
)}
isLoading={containersQuery.isLoading}
isRowSelectable={(row) => !row.original.IsPortainer}
initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter(
(colInstance) => hidableColumns?.includes(colInstance.id)
);
return (
<>
<ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide}
onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns);
}}
value={settings.hiddenColumns}
/>
<TableSettingsMenu
quickActions={<QuickActionsSettings actions={actions} />}
>
<ContainersDatatableSettings
isRefreshVisible
settings={settings}
return (
<>
<ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide}
onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns);
}}
value={settings.hiddenColumns}
/>
</TableSettingsMenu>
</>
);
}}
storageKey={storageKey}
dataset={containersQuery.data || []}
emptyContentLabel="No containers found"
/>
<TableSettingsMenu
quickActions={<QuickActionsSettings actions={actions} />}
>
<ContainersDatatableSettings
isRefreshVisible
settings={settings}
/>
</TableSettingsMenu>
</>
);
}}
dataset={containersQuery.data || []}
emptyContentLabel="No containers found"
/>
</TableSettingsProvider>
</RowProvider>
);
}

View File

@ -4,7 +4,7 @@ import { useSref } from '@uirouter/react';
import type { DockerContainer } from '@/react/docker/containers/types';
import { useTableSettings } from '@@/datatables/useZustandTableSettings';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { TableSettings } from '../types';
@ -31,7 +31,7 @@ export function NameCell({
nodeName: container.NodeName,
});
const { settings } = useTableSettings<TableSettings>();
const settings = useTableSettings<TableSettings>();
const truncate = settings.truncateContainerName;
let shortName = name;

View File

@ -4,7 +4,7 @@ import { useAuthorizations } from '@/react/hooks/useUser';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
import { DockerContainer } from '@/react/docker/containers/types';
import { useTableSettings } from '@@/datatables/useZustandTableSettings';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { TableSettings } from '../types';
@ -22,7 +22,7 @@ export const quickActions: Column<DockerContainer> = {
function QuickActionsCell({
row: { original: container },
}: CellProps<DockerContainer>) {
const { settings } = useTableSettings<TableSettings>();
const settings = useTableSettings<TableSettings>();
const { hiddenQuickActions = [] } = settings;

View File

@ -1,40 +1,26 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
refreshableSettings,
hiddenColumnsSettings,
} from '@/react/components/datatables/types';
createPersistedStore,
} from '@@/datatables/types';
import { QuickAction, TableSettings } from './types';
export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
...hiddenColumnsSettings(set),
...refreshableSettings(set),
truncateContainerName: TRUNCATE_LENGTH,
setTruncateContainerName(truncateContainerName: number) {
set({
truncateContainerName,
});
},
return createPersistedStore<TableSettings>(storageKey, 'Name', (set) => ({
...hiddenColumnsSettings(set),
...refreshableSettings(set),
truncateContainerName: TRUNCATE_LENGTH,
setTruncateContainerName(truncateContainerName: number) {
set({
truncateContainerName,
});
},
hiddenQuickActions: [] as QuickAction[],
setHiddenQuickActions: (hiddenQuickActions: QuickAction[]) =>
set({ hiddenQuickActions }),
}),
{
name: keyBuilder(storageKey),
}
)
);
hiddenQuickActions: [] as QuickAction[],
setHiddenQuickActions: (hiddenQuickActions: QuickAction[]) =>
set({ hiddenQuickActions }),
}));
}

View File

@ -1,9 +1,8 @@
import {
PaginationTableSettings,
BasicTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
} from '@@/datatables/types';
export type QuickAction = 'attach' | 'exec' | 'inspect' | 'logs' | 'stats';
@ -13,8 +12,7 @@ export interface SettableQuickActionsTableSettings<TAction> {
}
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings,
extends BasicTableSettings,
SettableColumnsTableSettings,
SettableQuickActionsTableSettings<QuickAction>,
RefreshableTableSettings {

View File

@ -38,63 +38,59 @@ export function NetworkContainersTable({
}
return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<TableContainer>
<TableTitle label="Containers in network" icon="server" featherIcon />
<Table className="nopadding">
<DetailsTable
headers={tableHeaders}
dataCy="networkDetails-networkContainers"
>
{networkContainers.map((container) => (
<tr key={container.Id}>
<td>
<Link
to="docker.containers.container"
params={{
id: container.Id,
nodeName,
}}
title={container.Name}
>
{container.Name}
</Link>
</td>
<td>{container.IPv4Address || '-'}</td>
<td>{container.IPv6Address || '-'}</td>
<td>{container.MacAddress || '-'}</td>
<td>
<Authorized authorizations="DockerNetworkDisconnect">
<Button
data-cy={`networkDetails-disconnect${container.Name}`}
size="xsmall"
color="dangerlight"
onClick={() => {
if (container.Id) {
disconnectContainer.mutate({
containerId: container.Id,
environmentId,
networkId,
});
}
}}
>
<Icon
icon="trash-2"
feather
class-name="icon-secondary icon-md"
/>
Leave Network
</Button>
</Authorized>
</td>
</tr>
))}
</DetailsTable>
</Table>
</TableContainer>
</div>
</div>
<TableContainer>
<TableTitle label="Containers in network" icon="server" featherIcon />
<Table className="nopadding">
<DetailsTable
headers={tableHeaders}
dataCy="networkDetails-networkContainers"
>
{networkContainers.map((container) => (
<tr key={container.Id}>
<td>
<Link
to="docker.containers.container"
params={{
id: container.Id,
nodeName,
}}
title={container.Name}
>
{container.Name}
</Link>
</td>
<td>{container.IPv4Address || '-'}</td>
<td>{container.IPv6Address || '-'}</td>
<td>{container.MacAddress || '-'}</td>
<td>
<Authorized authorizations="DockerNetworkDisconnect">
<Button
data-cy={`networkDetails-disconnect${container.Name}`}
size="xsmall"
color="dangerlight"
onClick={() => {
if (container.Id) {
disconnectContainer.mutate({
containerId: container.Id,
environmentId,
networkId,
});
}
}}
>
<Icon
icon="trash-2"
feather
class-name="icon-secondary icon-md"
/>
Leave Network
</Button>
</Authorized>
</td>
</tr>
))}
</DetailsTable>
</Table>
</TableContainer>
);
}

View File

@ -29,86 +29,80 @@ export function NetworkDetailsTable({
);
return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<TableContainer>
<TableTitle label="Network details" icon="share-2" featherIcon />
<Table className="nopadding">
<DetailsTable dataCy="networkDetails-detailsTable">
{/* networkRowContent */}
<DetailsTable.Row label="Name">{network.Name}</DetailsTable.Row>
<DetailsTable.Row label="Id">
{network.Id}
{allowRemoveNetwork && (
<Authorized authorizations="DockerNetworkDelete">
<Button
data-cy="networkDetails-deleteNetwork"
size="xsmall"
color="danger"
onClick={() => onRemoveNetworkClicked()}
>
<Icon
icon="trash-2"
feather
className="space-right"
aria-hidden="true"
/>
Delete this network
</Button>
</Authorized>
)}
</DetailsTable.Row>
<DetailsTable.Row label="Driver">
{network.Driver}
</DetailsTable.Row>
<DetailsTable.Row label="Scope">{network.Scope}</DetailsTable.Row>
<DetailsTable.Row label="Attachable">
{String(network.Attachable)}
</DetailsTable.Row>
<DetailsTable.Row label="Internal">
{String(network.Internal)}
</DetailsTable.Row>
<TableContainer>
<TableTitle label="Network details" icon="share-2" featherIcon />
<Table className="nopadding">
<DetailsTable dataCy="networkDetails-detailsTable">
{/* networkRowContent */}
<DetailsTable.Row label="Name">{network.Name}</DetailsTable.Row>
<DetailsTable.Row label="Id">
{network.Id}
{allowRemoveNetwork && (
<Authorized authorizations="DockerNetworkDelete">
<Button
data-cy="networkDetails-deleteNetwork"
size="xsmall"
color="danger"
onClick={() => onRemoveNetworkClicked()}
>
<Icon
icon="trash-2"
feather
className="space-right"
aria-hidden="true"
/>
Delete this network
</Button>
</Authorized>
)}
</DetailsTable.Row>
<DetailsTable.Row label="Driver">{network.Driver}</DetailsTable.Row>
<DetailsTable.Row label="Scope">{network.Scope}</DetailsTable.Row>
<DetailsTable.Row label="Attachable">
{String(network.Attachable)}
</DetailsTable.Row>
<DetailsTable.Row label="Internal">
{String(network.Internal)}
</DetailsTable.Row>
{/* IPV4 ConfigRowContent */}
{ipv4Configs.map((config) => (
<Fragment key={config.Subnet}>
<DetailsTable.Row
label={`IPV4 Subnet${getConfigDetails(config.Subnet)}`}
>
{`IPV4 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row>
<DetailsTable.Row
label={`IPV4 IP Range${getConfigDetails(config.IPRange)}`}
>
{`IPV4 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses
)}`}
</DetailsTable.Row>
</Fragment>
))}
{/* IPV4 ConfigRowContent */}
{ipv4Configs.map((config) => (
<Fragment key={config.Subnet}>
<DetailsTable.Row
label={`IPV4 Subnet${getConfigDetails(config.Subnet)}`}
>
{`IPV4 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row>
<DetailsTable.Row
label={`IPV4 IP Range${getConfigDetails(config.IPRange)}`}
>
{`IPV4 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses
)}`}
</DetailsTable.Row>
</Fragment>
))}
{/* IPV6 ConfigRowContent */}
{ipv6Configs.map((config) => (
<Fragment key={config.Subnet}>
<DetailsTable.Row
label={`IPV6 Subnet${getConfigDetails(config.Subnet)}`}
>
{`IPV6 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row>
<DetailsTable.Row
label={`IPV6 IP Range${getConfigDetails(config.IPRange)}`}
>
{`IPV6 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses
)}`}
</DetailsTable.Row>
</Fragment>
))}
</DetailsTable>
</Table>
</TableContainer>
</div>
</div>
{/* IPV6 ConfigRowContent */}
{ipv6Configs.map((config) => (
<Fragment key={config.Subnet}>
<DetailsTable.Row
label={`IPV6 Subnet${getConfigDetails(config.Subnet)}`}
>
{`IPV6 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row>
<DetailsTable.Row
label={`IPV6 IP Range${getConfigDetails(config.IPRange)}`}
>
{`IPV6 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses
)}`}
</DetailsTable.Row>
</Fragment>
))}
</DetailsTable>
</Table>
</TableContainer>
);
function getConfigDetails(configValue?: string) {

View File

@ -15,21 +15,17 @@ export function NetworkOptionsTable({ options }: Props) {
}
return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<TableContainer>
<TableTitle label="Network options" icon="share-2" featherIcon />
<Table className="nopadding">
<DetailsTable dataCy="networkDetails-networkOptionsTable">
{networkEntries.map(([key, value]) => (
<DetailsTable.Row key={key} label={key}>
{value}
</DetailsTable.Row>
))}
</DetailsTable>
</Table>
</TableContainer>
</div>
</div>
<TableContainer>
<TableTitle label="Network options" icon="share-2" featherIcon />
<Table className="nopadding">
<DetailsTable dataCy="networkDetails-networkOptionsTable">
{networkEntries.map(([key, value]) => (
<DetailsTable.Row key={key} label={key}>
{value}
</DetailsTable.Row>
))}
</DetailsTable>
</Table>
</TableContainer>
);
}

View File

@ -1,4 +1,6 @@
import _ from 'lodash';
import { useStore } from 'zustand';
import { Box } from 'react-feather';
import { DockerContainer } from '@/react/docker/containers/types';
import { Environment } from '@/react/portainer/environments/types';
@ -14,12 +16,14 @@ import {
QuickActionsSettings,
} from '@@/datatables/QuickActionsSettings';
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { useContainers } from '../../containers/queries/containers';
import { RowProvider } from '../../containers/ListView/ContainersDatatable/RowContext';
const storageKey = 'stack-containers';
const useStore = createStore(storageKey);
const settingsStore = createStore(storageKey);
const actions = [
buildAction('logs', 'Logs'),
@ -35,9 +39,12 @@ export interface Props {
}
export function StackContainersDatatable({ environment, stackName }: Props) {
const settings = useStore();
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const isGPUsColumnVisible = useShowGPUsColumn(environment.Id);
const columns = useColumns(false, isGPUsColumnVisible);
const hidableColumns = _.compact(
columns.filter((col) => col.canHide).map((col) => col.id)
);
@ -53,49 +60,53 @@ export function StackContainersDatatable({ environment, stackName }: Props) {
return (
<RowProvider context={{ environment }}>
<Datatable
titleOptions={{
icon: 'fa-cubes',
title: 'Containers',
}}
settingsStore={settings}
columns={columns}
renderTableActions={(selectedRows) => (
<ContainersDatatableActions
selectedItems={selectedRows}
isAddActionVisible={false}
endpointId={environment.Id}
/>
)}
initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter((colInstance) =>
hidableColumns?.includes(colInstance.id)
);
<TableSettingsProvider settings={settingsStore}>
<Datatable
title="Containers"
titleIcon={Box}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
columns={columns}
renderTableActions={(selectedRows) => (
<ContainersDatatableActions
selectedItems={selectedRows}
isAddActionVisible={false}
endpointId={environment.Id}
/>
)}
initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter(
(colInstance) => hidableColumns?.includes(colInstance.id)
);
return (
<>
<ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide}
onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns);
}}
value={settings.hiddenColumns}
/>
<TableSettingsMenu
quickActions={<QuickActionsSettings actions={actions} />}
>
<ContainersDatatableSettings settings={settings} />
</TableSettingsMenu>
</>
);
}}
storageKey={storageKey}
dataset={containersQuery.data || []}
isLoading={containersQuery.isLoading}
emptyContentLabel="No containers found"
/>
return (
<>
<ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide}
onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns);
}}
value={settings.hiddenColumns}
/>
<TableSettingsMenu
quickActions={<QuickActionsSettings actions={actions} />}
>
<ContainersDatatableSettings settings={settings} />
</TableSettingsMenu>
</>
);
}}
dataset={containersQuery.data || []}
isLoading={containersQuery.isLoading}
emptyContentLabel="No containers found"
/>
</TableSettingsProvider>
</RowProvider>
);
}

View File

@ -1,101 +1,30 @@
import { usePagination, useTable } from 'react-table';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
import { EnvironmentId } from '@/react/portainer/environments/types';
import PortainerError from '@/portainer/error';
import { InnerDatatable } from '@@/datatables/InnerDatatable';
import { Table, TableContainer, TableHeaderRow, TableRow } from '@@/datatables';
import { NestedDatatable } from '@@/datatables/NestedDatatable';
import { useAMTDevices } from './useAMTDevices';
import { RowProvider } from './columns/RowContext';
import { useColumns } from './columns';
import { columns } from './columns';
export interface AMTDevicesTableProps {
environmentId: EnvironmentId;
}
export function AMTDevicesDatatable({ environmentId }: AMTDevicesTableProps) {
const columns = useColumns();
const { isLoading, devices, error } = useAMTDevices(environmentId);
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
useTable<Device>(
{
columns,
data: devices,
},
usePagination
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const devicesQuery = useAMTDevices(environmentId);
return (
<InnerDatatable>
<TableContainer>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Device>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{!isLoading && devices && devices.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<RowProvider key={key} environmentId={environmentId}>
<TableRow<Device>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
</RowProvider>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
{userMessage(isLoading, error)}
</td>
</tr>
)}
</tbody>
</Table>
</TableContainer>
</InnerDatatable>
<NestedDatatable
columns={columns}
dataset={devicesQuery.devices}
isLoading={devicesQuery.isLoading}
emptyContentLabel={userMessage(devicesQuery.error)}
defaultSortBy="hostname"
/>
);
}
function userMessage(isLoading: boolean, error?: PortainerError) {
if (isLoading) {
return 'Loading...';
}
function userMessage(error?: PortainerError) {
if (error) {
return error.message;
}

View File

@ -1,10 +1,6 @@
import { useMemo } from 'react';
import { hostname } from './hostname';
import { status } from './status';
import { powerState } from './power-state';
import { actions } from './actions';
export function useColumns() {
return useMemo(() => [hostname, status, powerState, actions], []);
}
export const columns = [hostname, status, powerState, actions];

View File

@ -1,37 +1,26 @@
import { useTable, useExpanded, useSortBy, useFilters } from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import _ from 'lodash';
import { useStore } from 'zustand';
import { Box } from 'react-feather';
import { useState } from 'react';
import { Environment } from '@/react/portainer/environments/types';
import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
import { PaginationControls } from '@@/PaginationControls';
import {
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableSettingsMenu,
TableTitle,
TableTitleActions,
} from '@@/datatables';
import { multiple } from '@@/datatables/filter-types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { TableSettingsMenu } from '@@/datatables';
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { SearchBar } from '@@/datatables/SearchBar';
import { useRowSelect } from '@@/datatables/useRowSelect';
import { TableFooter } from '@@/datatables/TableFooter';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { AMTDevicesDatatable } from './AMTDevicesDatatable';
import { columns } from './columns';
import { EdgeDevicesDatatableActions } from './EdgeDevicesDatatableActions';
import { EdgeDevicesDatatableSettings } from './EdgeDevicesDatatableSettings';
import { RowProvider } from './columns/RowContext';
import { useColumns } from './columns';
import styles from './EdgeDevicesDatatable.module.css';
import { EdgeDeviceTableSettings, Pagination } from './types';
import { createStore } from './datatable-store';
export interface EdgeDevicesTableProps {
storageKey: string;
@ -39,231 +28,123 @@ export interface EdgeDevicesTableProps {
isOpenAmtEnabled: boolean;
showWaitingRoomLink: boolean;
mpsServer: string;
dataset: Environment[];
groups: EnvironmentGroup[];
setLoadingMessage(message: string): void;
pagination: Pagination;
onChangePagination(pagination: Partial<Pagination>): void;
totalCount: number;
search: string;
onChangeSearch(search: string): void;
}
const storageKey = 'edgeDevices';
const settingsStore = createStore(storageKey);
export function EdgeDevicesDatatable({
isFdoEnabled,
isOpenAmtEnabled,
showWaitingRoomLink,
mpsServer,
dataset,
onChangeSearch,
search,
groups,
setLoadingMessage,
pagination,
onChangePagination,
totalCount,
}: EdgeDevicesTableProps) {
const { settings, setTableSettings } =
useTableSettings<EdgeDeviceTableSettings>();
const settings = useStore(settingsStore);
const [page, setPage] = useState(0);
const columns = useColumns();
const [search, setSearch] = useSearchBarState(storageKey);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
selectedFlatRows,
allColumns,
setHiddenColumns,
} = useTable<Environment>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
hiddenColumns: settings.hiddenColumns,
sortBy: [settings.sortBy],
},
isRowSelectable() {
return true;
},
autoResetExpanded: false,
autoResetSelectedRows: false,
getRowId(originalRow: Environment) {
return originalRow.Id.toString();
},
selectColumnWidth: 5,
},
useFilters,
useSortBy,
useExpanded,
useRowSelect,
useRowSelectColumn
const hidableColumns = _.compact(
columns.filter((col) => col.canHide).map((col) => col.id)
);
const columnsToHide = allColumns.filter((colInstance) => {
const columnDef = columns.find((c) => c.id === colInstance.id);
return columnDef?.canHide;
});
const { environments, isLoading, totalCount } = useEnvironmentList(
{
edgeDevice: true,
search,
types: EdgeTypes,
excludeSnapshots: true,
page: page + 1,
pageLimit: settings.pageSize,
sort: settings.sortBy.id,
order: settings.sortBy.desc ? 'desc' : 'asc',
},
settings.autoRefreshRate * 1000
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const someDeviceHasAMTActivated = dataset.some(
const someDeviceHasAMTActivated = environments.some(
(environment) =>
environment.AMTDeviceGUID && environment.AMTDeviceGUID !== ''
);
const groupsById = _.groupBy(groups, 'Id');
return (
<div className="row">
<div className="col-sm-12">
<TableContainer>
<TableTitle icon="box" featherIcon label="Edge Devices">
<SearchBar value={search} onChange={handleSearchBarChange} />
<TableActions>
<EdgeDevicesDatatableActions
selectedItems={selectedFlatRows.map((row) => row.original)}
isFDOEnabled={isFdoEnabled}
isOpenAMTEnabled={isOpenAmtEnabled}
setLoadingMessage={setLoadingMessage}
showWaitingRoomLink={showWaitingRoomLink}
/>
</TableActions>
<TableTitleActions>
<ColumnVisibilityMenu<Environment>
columns={columnsToHide}
onChange={handleChangeColumnsVisibility}
value={settings.hiddenColumns}
/>
<TableSettingsMenu>
<EdgeDevicesDatatableSettings />
</TableSettingsMenu>
</TableTitleActions>
</TableTitle>
{isOpenAmtEnabled && someDeviceHasAMTActivated && (
<div className={styles.kvmTip}>
<TextTip color="blue">
For the KVM function to work you need to have the MPS server
added to your trusted site list, browse to this{' '}
<a
href={`https://${mpsServer}`}
target="_blank"
rel="noreferrer"
className="space-right"
>
site
</a>
and add to your trusted site list
</TextTip>
</div>
<>
{isOpenAmtEnabled && someDeviceHasAMTActivated && (
<InformationPanel>
<div className={styles.kvmTip}>
<TextTip color="blue">
For the KVM function to work you need to have the MPS server added
to your trusted site list, browse to this
<a
href={`https://${mpsServer}`}
target="_blank"
rel="noreferrer"
className="mx-px"
>
site
</a>
and add to your trusted site list
</TextTip>
</div>
</InformationPanel>
)}
<RowProvider context={{ isOpenAmtEnabled, groups }}>
<ExpandableDatatable
dataset={environments}
columns={columns}
isLoading={isLoading}
totalCount={totalCount}
title="Edge Devices"
titleIcon={Box}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
renderSubRow={(row) => (
<tr>
<td />
<td colSpan={row.cells.length - 1}>
<AMTDevicesDatatable environmentId={row.original.Id} />
</td>
</tr>
)}
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Environment>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content
prepareRow={prepareRow}
rows={rows}
renderRow={(row, { key, className, role, style }) => {
const group = groupsById[row.original.GroupId];
return (
<RowProvider
key={key}
context={{ isOpenAmtEnabled, groupName: group[0]?.Name }}
>
<TableRow<Environment>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
{row.isExpanded && (
<tr>
<td />
<td colSpan={row.cells.length - 1}>
<AMTDevicesDatatable
environmentId={row.original.Id}
/>
</td>
</tr>
)}
</RowProvider>
);
}}
/>
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
isPageInputVisible
pageLimit={pagination.pageLimit}
page={pagination.page}
onPageChange={(p) => gotoPage(p)}
totalCount={totalCount}
onPageLimitChange={handlePageSizeChange}
initialTableState={{ pageIndex: page }}
pageCount={Math.ceil(totalCount / settings.pageSize)}
renderTableActions={(selectedRows) => (
<EdgeDevicesDatatableActions
selectedItems={selectedRows}
isFDOEnabled={isFdoEnabled}
isOpenAMTEnabled={isOpenAmtEnabled}
showWaitingRoomLink={showWaitingRoomLink}
/>
</TableFooter>
</TableContainer>
</div>
</div>
)}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter(
(colInstance) => hidableColumns?.includes(colInstance.id)
);
return (
<>
<ColumnVisibilityMenu<Environment>
columns={columnsToHide}
onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns);
}}
value={settings.hiddenColumns}
/>
<TableSettingsMenu>
<EdgeDevicesDatatableSettings settings={settings} />
</TableSettingsMenu>
</>
);
}}
onPageChange={setPage}
/>
</RowProvider>
</>
);
function gotoPage(pageIndex: number) {
onChangePagination({ page: pageIndex });
}
function setPageSize(pageSize: number) {
onChangePagination({ pageLimit: pageSize });
}
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleChangeColumnsVisibility(hiddenColumns: string[]) {
setHiddenColumns(hiddenColumns);
setTableSettings((settings) => ({ ...settings, hiddenColumns }));
}
function handleSearchBarChange(value: string) {
onChangeSearch(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
}

View File

@ -7,8 +7,8 @@ import {
} from '@/portainer/services/modal.service/confirm';
import { promptAsync } from '@/portainer/services/modal.service/prompt';
import * as notifications from '@/portainer/services/notifications';
import { activateDevice } from '@/portainer/hostmanagement/open-amt/open-amt.service';
import { deleteEndpoint } from '@/react/portainer/environments/environment.service';
import { useActivateDeviceMutation } from '@/portainer/hostmanagement/open-amt/queries';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
@ -17,7 +17,6 @@ interface Props {
selectedItems: Environment[];
isFDOEnabled: boolean;
isOpenAMTEnabled: boolean;
setLoadingMessage(message: string): void;
showWaitingRoomLink: boolean;
}
@ -30,10 +29,10 @@ export function EdgeDevicesDatatableActions({
selectedItems,
isOpenAMTEnabled,
isFDOEnabled,
setLoadingMessage,
showWaitingRoomLink,
}: Props) {
const router = useRouter();
const activateDeviceMutation = useActivateDeviceMutation();
return (
<div className="actionBar">
@ -169,23 +168,13 @@ export function EdgeDevicesDatatableActions({
return;
}
try {
setLoadingMessage(
'Activating Active Management Technology on selected device...'
);
await activateDevice(selectedEnvironment.Id);
notifications.success(
'Successfully associated with OpenAMT',
selectedEnvironment.Name
);
} catch (err) {
notifications.error(
'Failure',
err as Error,
'Unable to associate with OpenAMT'
);
} finally {
setLoadingMessage('');
}
activateDeviceMutation.mutate(selectedEnvironment.Id, {
onSuccess() {
notifications.notifySuccess(
'Successfully associated with OpenAMT',
selectedEnvironment.Name
);
},
});
}
}

View File

@ -1,121 +0,0 @@
import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { useSearchBarState } from '@@/datatables/SearchBar';
import {
TableSettingsProvider,
useTableSettings,
} from '@@/datatables/useTableSettings';
import {
EdgeDevicesDatatable,
EdgeDevicesTableProps,
} from './EdgeDevicesDatatable';
import { EdgeDeviceTableSettings, Pagination } from './types';
export function EdgeDevicesDatatableContainer({
...props
}: Omit<
EdgeDevicesTableProps,
| 'dataset'
| 'pagination'
| 'onChangePagination'
| 'totalCount'
| 'search'
| 'onChangeSearch'
>) {
const defaultSettings = {
autoRefreshRate: 0,
hiddenQuickActions: [],
hiddenColumns: [],
pageSize: 10,
sortBy: { id: 'state', desc: false },
};
const storageKey = 'edgeDevices';
return (
<TableSettingsProvider defaults={defaultSettings} storageKey={storageKey}>
<Loader storageKey={storageKey}>
{({
environments,
pagination,
totalCount,
setPagination,
search,
setSearch,
}) => (
<EdgeDevicesDatatable
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
storageKey={storageKey}
dataset={environments}
pagination={pagination}
onChangePagination={setPagination}
totalCount={totalCount}
search={search}
onChangeSearch={setSearch}
/>
)}
</Loader>
</TableSettingsProvider>
);
}
interface LoaderProps {
storageKey: string;
children: (options: {
environments: Environment[];
totalCount: number;
pagination: Pagination;
setPagination(value: Partial<Pagination>): void;
search: string;
setSearch: (value: string) => void;
}) => React.ReactNode;
}
function Loader({ children, storageKey }: LoaderProps) {
const { settings } = useTableSettings<EdgeDeviceTableSettings>();
const [pagination, setPagination] = useState({
pageLimit: settings.pageSize,
page: 1,
});
const [search, setSearch] = useSearchBarState(storageKey);
const debouncedSearchValue = useDebouncedValue(search);
const { environments, isLoading, totalCount } = useEnvironmentList(
{
edgeDevice: true,
search: debouncedSearchValue,
types: EdgeTypes,
excludeSnapshots: true,
...pagination,
},
settings.autoRefreshRate * 1000
);
if (isLoading) {
return null;
}
return (
<>
{children({
environments,
totalCount,
pagination,
setPagination: handleSetPagination,
search,
setSearch,
})}
</>
);
function handleSetPagination(value: Partial<Pagination>) {
setPagination((prev) => ({ ...prev, ...value }));
}
}

View File

@ -1,12 +1,11 @@
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { RefreshableTableSettings } from '@@/datatables/types';
import { EdgeDeviceTableSettings } from './types';
export function EdgeDevicesDatatableSettings() {
const { settings, setTableSettings } =
useTableSettings<EdgeDeviceTableSettings>();
interface Props {
settings: RefreshableTableSettings;
}
export function EdgeDevicesDatatableSettings({ settings }: Props) {
return (
<TableSettingsMenuAutoRefresh
value={settings.autoRefreshRate}
@ -15,6 +14,6 @@ export function EdgeDevicesDatatableSettings() {
);
function handleRefreshRateChange(autoRefreshRate: number) {
setTableSettings({ autoRefreshRate });
settings.setAutoRefreshRate(autoRefreshRate);
}
}

View File

@ -1,8 +1,10 @@
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { createRowContext } from '@@/datatables/RowContext';
interface RowContextState {
isOpenAmtEnabled: boolean;
groupName?: string;
groups: EnvironmentGroup[];
}
const { RowProvider, useRowContext } = createRowContext<RowContextState>();

View File

@ -1,6 +1,7 @@
import { Column } from 'react-table';
import { Environment } from '@/react/portainer/environments/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { DefaultFilter } from '@@/datatables/Filter';
@ -15,8 +16,9 @@ export const group: Column<Environment> = {
canHide: true,
};
function GroupCell() {
const { groupName } = useRowContext();
function GroupCell({ value }: { value: EnvironmentGroupId }) {
const { groups } = useRowContext();
const group = groups.find((g) => g.Id === value);
return groupName;
return group?.Name || '';
}

View File

@ -1,10 +1,6 @@
import { useMemo } from 'react';
import { name } from './name';
import { heartbeat } from './heartbeat';
import { group } from './group';
import { actions } from './actions';
export function useColumns() {
return useMemo(() => [name, heartbeat, group, actions], []);
}
export const columns = [name, heartbeat, group, actions];

View File

@ -0,0 +1,14 @@
import {
refreshableSettings,
hiddenColumnsSettings,
createPersistedStore,
} from '@@/datatables/types';
import { TableSettings } from './types';
export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, 'Name', (set) => ({
...hiddenColumnsSettings(set),
...refreshableSettings(set),
}));
}

View File

@ -1,17 +1,10 @@
import {
PaginationTableSettings,
BasicTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
} from '@@/datatables/types';
export interface Pagination {
pageLimit: number;
page: number;
}
export interface EdgeDeviceTableSettings
extends SortableTableSettings,
PaginationTableSettings,
export interface TableSettings
extends BasicTableSettings,
SettableColumnsTableSettings,
RefreshableTableSettings {}

View File

@ -1,16 +1,16 @@
import { useState } from 'react';
import { useIsMutating } from 'react-query';
import { useSettings } from '@/react/portainer/settings/queries';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { activateDeviceMutationKey } from '@/portainer/hostmanagement/open-amt/queries';
import { PageHeader } from '@@/PageHeader';
import { ViewLoading } from '@@/ViewLoading';
import { EdgeDevicesDatatableContainer } from './EdgeDevicesDatatable/EdgeDevicesDatatableContainer';
import { EdgeDevicesDatatable } from './EdgeDevicesDatatable/EdgeDevicesDatatable';
export function ListView() {
const [loadingMessage, setLoadingMessage] = useState('');
const isActivatingDevice = useIsActivatingDevice();
const settingsQuery = useSettings();
const groupsQuery = useGroups();
@ -28,11 +28,10 @@ export function ListView() {
breadcrumbs={[{ label: 'EdgeDevices' }]}
/>
{loadingMessage ? (
<ViewLoading message={loadingMessage} />
{isActivatingDevice ? (
<ViewLoading message="Activating Active Management Technology on selected device..." />
) : (
<EdgeDevicesDatatableContainer
setLoadingMessage={setLoadingMessage}
<EdgeDevicesDatatable
isFdoEnabled={
settings.EnableEdgeComputeFeatures &&
settings.fdoConfiguration.enabled
@ -54,3 +53,8 @@ export function ListView() {
</>
);
}
function useIsActivatingDevice() {
const count = useIsMutating({ mutationKey: activateDeviceMutationKey });
return count > 0;
}

View File

@ -1,207 +1,70 @@
import {
Column,
useGlobalFilter,
usePagination,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { useStore } from 'zustand';
import { Environment } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { Datatable as GenericDatatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { Table } from '@@/datatables';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { PaginationControls } from '@@/PaginationControls';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { TextTip } from '@@/Tip/TextTip';
import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { useAssociateDeviceMutation } from '../queries';
import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
import { TableSettings } from './types';
import { columns } from './columns';
const columns: readonly Column<Environment>[] = [
{
Header: 'Name',
accessor: (row) => row.Name,
id: 'name',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
{
Header: 'Edge ID',
accessor: (row) => row.EdgeID,
id: 'edge-id',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
] as const;
const storageKey = 'edge-devices-waiting-room';
const settingsStore = createPersistedStore(storageKey, 'Name');
interface Props {
devices: Environment[];
isLoading: boolean;
totalCount: number;
storageKey: string;
}
export function DataTable({
devices,
storageKey,
isLoading,
totalCount,
}: Props) {
export function Datatable({ devices, isLoading, totalCount }: Props) {
const associateMutation = useAssociateDeviceMutation();
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
const { settings, setTableSettings } = useTableSettings<TableSettings>();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<Environment>(
{
defaultCanFilter: false,
columns,
data: devices,
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
isRowSelectable() {
return true;
},
autoResetSelectedRows: false,
getRowId(originalRow: Environment) {
return originalRow.Id.toString();
},
selectColumnWidth: 5,
},
useGlobalFilter,
useSortBy,
usePagination,
useRowSelect,
useRowSelectColumn
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const licenseOverused = useLicenseOverused();
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
return (
<div className="row">
<div className="col-sm-12">
<Table.Container>
<Table.Title label="Edge Devices Waiting Room" icon="">
<SearchBar
onChange={handleSearchBarChange}
value={searchBarValue}
/>
<Table.Actions>
<Button
onClick={() =>
handleAssociateDevice(selectedFlatRows.map((r) => r.original))
}
disabled={selectedFlatRows.length === 0}
>
Associate Device
</Button>
</Table.Actions>
</Table.Title>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
<GenericDatatable
columns={columns}
dataset={devices}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
title="Edge Devices Waiting Room"
emptyContentLabel="No Edge Devices found"
renderTableActions={(selectedRows) => (
<>
<Button
onClick={() => handleAssociateDevice(selectedRows)}
disabled={selectedRows.length === 0}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
Associate Device
</Button>
return (
<Table.HeaderRow<Environment>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content
emptyContent="No Edge Devices found"
prepareRow={prepareRow}
rows={page}
isLoading={isLoading}
renderRow={(row, { key, className, role, style }) => (
<Table.Row
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
/>
</tbody>
</Table>
<Table.Footer>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={totalCount}
onPageLimitChange={handlePageLimitChange}
/>
</Table.Footer>
</Table.Container>
</div>
</div>
{licenseOverused ? (
<div className="ml-2 mt-2">
<TextTip color="orange">
Associating devices is disabled as your node count exceeds your
license limit
</TextTip>
</div>
) : null}
</>
)}
isLoading={isLoading}
totalCount={totalCount}
/>
);
function handleSortChange(colId: string, desc: boolean) {
setTableSettings({ sortBy: { id: colId, desc } });
}
function handlePageLimitChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings({ pageSize });
}
function handleSearchBarChange(value: string) {
setGlobalFilter(value);
setSearchBarValue(value);
}
function handleAssociateDevice(devices: Environment[]) {
associateMutation.mutate(
devices.map((d) => d.Id),

View File

@ -0,0 +1,24 @@
import { Column } from 'react-table';
import { Environment } from '@/react/portainer/environments/types';
export const columns: readonly Column<Environment>[] = [
{
Header: 'Name',
accessor: (row) => row.Name,
id: 'name',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
{
Header: 'Edge ID',
accessor: (row) => row.EdgeID,
id: 'edge-id',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
] as const;

View File

@ -0,0 +1 @@
export { Datatable } from './Datatable';

View File

@ -1,8 +0,0 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings {}

View File

@ -5,14 +5,11 @@ import { EdgeTypes } from '@/react/portainer/environments/types';
import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { PageHeader } from '@@/PageHeader';
import { DataTable } from './Datatable/Datatable';
import { TableSettings } from './Datatable/types';
import { Datatable } from './Datatable';
export function WaitingRoomView() {
const storageKey = 'edge-devices-waiting-room';
const router = useRouter();
const { environments, isLoading, totalCount } = useEnvironmentList({
edgeDevice: true,
@ -44,17 +41,11 @@ export function WaitingRoomView() {
</TextTip>
</InformationPanel>
<TableSettingsProvider<TableSettings>
defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }}
storageKey={storageKey}
>
<DataTable
devices={environments}
totalCount={totalCount}
isLoading={isLoading}
storageKey={storageKey}
/>
</TableSettingsProvider>
<Datatable
devices={environments}
totalCount={totalCount}
isLoading={isLoading}
/>
</>
);
}

View File

@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import { useIntegratedLicenseInfo } from '@/portainer/license-management/use-license.service';
export function useAssociateDeviceMutation() {
const queryClient = useQueryClient();
@ -31,3 +32,11 @@ async function associateDevice(environmentId: EnvironmentId) {
throw parseAxiosError(e as Error, 'Failed to associate device');
}
}
export function useLicenseOverused() {
const integratedInfo = useIntegratedLicenseInfo();
if (integratedInfo && integratedInfo.licenseInfo.enforcedAt > 0) {
return true;
}
return false;
}

View File

@ -1,17 +1,21 @@
import { useEffect, useState } from 'react';
import { Database } from 'react-feather';
import { useStore } from 'zustand';
import { confirmWarn } from '@/portainer/services/modal.service/confirm';
import { Datatable } from '@@/datatables';
import { Button, ButtonGroup } from '@@/buttons';
import { Icon } from '@@/Icon';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { createPersistedStore } from '@@/datatables/types';
import { IngressControllerClassMap } from '../types';
import { useColumns } from './columns';
import { createStore } from './datatable-store';
const useStore = createStore('ingressClasses');
const storageKey = 'ingressClasses';
const settingsStore = createPersistedStore(storageKey);
interface Props {
onChangeControllers: (
@ -34,10 +38,11 @@ export function IngressClassDatatable({
noIngressControllerLabel,
view,
}: Props) {
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const [ingControllerFormValues, setIngControllerFormValues] = useState(
ingressControllers || []
);
const settings = useStore();
const columns = useColumns();
useEffect(() => {
@ -76,19 +81,20 @@ export function IngressClassDatatable({
<div className="-mx-[15px]">
<Datatable
dataset={ingControllerFormValues || []}
storageKey="ingressClasses"
columns={columns}
settingsStore={settings}
isLoading={isLoading}
emptyContentLabel={noIngressControllerLabel}
titleOptions={{
icon: 'database',
title: 'Ingress controllers',
featherIcon: true,
}}
title="Ingress Controllers"
titleIcon={Database}
getRowId={(row) => `${row.Name}-${row.ClassName}-${row.Type}`}
renderTableActions={(selectedRows) => renderTableActions(selectedRows)}
description={renderIngressClassDescription()}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
/>
</div>
);

View File

@ -1,26 +0,0 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
} from '@/react/components/datatables/types';
import { TableSettings } from './types';
export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View File

@ -1,5 +1,6 @@
import { Plus, Trash2 } from 'react-feather';
import { useRouter } from '@uirouter/react';
import { useStore } from 'zustand';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
@ -9,11 +10,12 @@ import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { DeleteIngressesRequest, Ingress } from '../types';
import { useDeleteIngresses, useIngresses } from '../queries';
import { createStore } from './datatable-store';
import { useColumns } from './columns';
import '../style.css';
@ -22,10 +24,11 @@ interface SelectedIngress {
Namespace: string;
Name: string;
}
const storageKey = 'ingressClassesNameSpace';
const useStore = createStore('ingresses');
const settingsStore = createPersistedStore(storageKey);
export function IngressDataTable() {
export function IngressDatatable() {
const environmentId = useEnvironmentId();
const nsResult = useNamespaces(environmentId);
@ -34,28 +37,30 @@ export function IngressDataTable() {
Object.keys(nsResult?.data || {})
);
const settings = useStore();
const columns = useColumns();
const deleteIngressesMutation = useDeleteIngresses();
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const router = useRouter();
return (
<Datatable
dataset={ingressesQuery.data || []}
storageKey="ingressClassesNameSpace"
columns={columns}
settingsStore={settings}
isLoading={ingressesQuery.isLoading}
emptyContentLabel="No supported ingresses found"
titleOptions={{
icon: 'svg-route',
title: 'Ingresses',
}}
title="Ingresses"
titleIcon="svg-route"
getRowId={(row) => row.Name + row.Type + row.Namespace}
renderTableActions={tableActions}
disableSelect={useCheckboxes()}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
/>
);
@ -67,14 +72,7 @@ export function IngressDataTable() {
className="btn-wrapper"
color="dangerlight"
disabled={selectedFlatRows.length === 0}
onClick={() =>
handleRemoveClick(
selectedFlatRows.map((row) => ({
Name: row.Name,
Namespace: row.Namespace,
}))
)
}
onClick={() => handleRemoveClick(selectedFlatRows)}
icon={Trash2}
>
Remove

View File

@ -1,26 +0,0 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
} from '@/react/components/datatables/types';
import { TableSettings } from '../types';
export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View File

@ -1,6 +1,6 @@
import { PageHeader } from '@@/PageHeader';
import { IngressDataTable } from './IngressDataTable';
import { IngressDatatable } from './IngressDatatable';
export function IngressesDatatableView() {
return (
@ -14,7 +14,7 @@ export function IngressesDatatableView() {
]}
reload
/>
<IngressDataTable />
<IngressDatatable />
</>
);
}

View File

@ -1,28 +1,10 @@
import { Fragment, useEffect } from 'react';
import {
useFilters,
useGlobalFilter,
usePagination,
useSortBy,
useTable,
} from 'react-table';
import { useStore } from 'zustand';
import { NomadEvent } from '@/react/nomad/types';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { PaginationControls } from '@@/PaginationControls';
import {
Table,
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
} from '@@/datatables';
import { multiple } from '@@/datatables/filter-types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { TableFooter } from '@@/datatables/TableFooter';
import { TableContent } from '@@/datatables/TableContent';
import { Datatable } from '@@/datatables';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { createPersistedStore } from '@@/datatables/types';
import { useColumns } from './columns';
@ -31,133 +13,31 @@ export interface EventsDatatableProps {
isLoading: boolean;
}
export interface EventsTableSettings {
autoRefreshRate: number;
pageSize: number;
sortBy: { id: string; desc: boolean };
}
const storageKey = 'events';
const settingsStore = createPersistedStore(storageKey, 'Date');
export function EventsDatatable({ data, isLoading }: EventsDatatableProps) {
const { settings, setTableSettings } =
useTableSettings<EventsTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarState('events');
const columns = useColumns();
const debouncedSearchValue = useDebouncedValue(searchBarValue);
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<NomadEvent>(
{
defaultCanFilter: false,
columns,
data,
filterTypes: { multiple },
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
},
useFilters,
useGlobalFilter,
useSortBy,
usePagination
);
useEffect(() => {
setGlobalFilter(debouncedSearchValue);
}, [debouncedSearchValue, setGlobalFilter]);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
return (
<TableContainer>
<TableTitle icon="fa-history" label="Events" />
<SearchBar value={searchBarValue} onChange={handleSearchBarChange} />
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<NomadEvent>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<TableContent
rows={page}
prepareRow={prepareRow}
isLoading={isLoading}
emptyContent="No events found"
renderRow={(row, { key, className, role, style }) => (
<Fragment key={key}>
<TableRow<NomadEvent>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
</Fragment>
)}
/>
</tbody>
</Table>
<TableFooter>
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={data.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
<Datatable
isLoading={isLoading}
columns={columns}
dataset={data}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
titleIcon="fa-history"
title="Events"
totalCount={data.length}
getRowId={(row) => `${row.Date}-${row.Message}-${row.Type}`}
disableSelect
/>
);
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
}

View File

@ -1,9 +1,7 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { NomadEventsList } from '@/react/nomad/types';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { PageHeader } from '@@/PageHeader';
import { EventsDatatable } from './EventsDatatable';
@ -27,14 +25,8 @@ export function EventsView() {
{ label: 'Events' },
];
const defaultSettings = {
pageSize: 10,
sortBy: {},
};
return (
<>
{/* header */}
<PageHeader
title="Event list"
breadcrumbs={breadcrumbs}
@ -43,20 +35,7 @@ export function EventsView() {
onReload={invalidateQuery}
/>
<div className="row">
<div className="col-sm-12">
<TableSettingsProvider
defaults={defaultSettings}
storageKey="nomad-events"
>
{/* events table */}
<EventsDatatable
data={(query.data || []) as NomadEventsList}
isLoading={query.isLoading}
/>
</TableSettingsProvider>
</div>
</div>
<EventsDatatable data={query.data || []} isLoading={query.isLoading} />
</>
);
}

View File

@ -1,40 +1,16 @@
import { Fragment, useEffect } from 'react';
import {
useExpanded,
useFilters,
useGlobalFilter,
usePagination,
useSortBy,
useTable,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { useStore } from 'zustand';
import { Clock } from 'react-feather';
import { Job } from '@/react/nomad/types';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { PaginationControls } from '@@/PaginationControls';
import {
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
TableSettingsMenu,
TableTitleActions,
} from '@@/datatables';
import { multiple } from '@@/datatables/filter-types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { useRowSelect } from '@@/datatables/useRowSelect';
import { TableFooter } from '@@/datatables/TableFooter';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { TableContent } from '@@/datatables/TableContent';
import { useRepeater } from '@@/datatables/useRepeater';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { TableSettingsMenu } from '@@/datatables';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { JobsTableSettings } from './types';
import { TasksDatatable } from './TasksDatatable';
import { useColumns } from './columns';
import { columns } from './columns';
import { createStore } from './datatable-store';
import { JobsDatatableSettings } from './JobsDatatableSettings';
export interface JobsDatatableProps {
@ -43,162 +19,39 @@ export interface JobsDatatableProps {
isLoading?: boolean;
}
const storageKey = 'jobs';
const settingsStore = createStore(storageKey);
export function JobsDatatable({
jobs,
refreshData,
isLoading,
}: JobsDatatableProps) {
const { settings, setTableSettings } = useTableSettings<JobsTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarState('jobs');
const columns = useColumns();
const debouncedSearchValue = useDebouncedValue(searchBarValue);
const [search, setSearch] = useSearchBarState(storageKey);
const settings = useStore(settingsStore);
useRepeater(settings.autoRefreshRate, refreshData);
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<Job>(
{
defaultCanFilter: false,
columns,
data: jobs,
filterTypes: { multiple },
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
isRowSelectable() {
return false;
},
autoResetExpanded: false,
autoResetSelectedRows: false,
selectColumnWidth: 5,
getRowId(job, relativeIndex) {
return `${job.ID}-${relativeIndex}`;
},
},
useFilters,
useGlobalFilter,
useSortBy,
useExpanded,
usePagination,
useRowSelect,
useRowSelectColumn
);
useEffect(() => {
setGlobalFilter(debouncedSearchValue);
}, [debouncedSearchValue, setGlobalFilter]);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return (
<TableContainer>
<TableTitle icon="fa-cubes" label="Nomad Jobs">
<TableTitleActions>
<TableSettingsMenu>
<JobsDatatableSettings />
</TableSettingsMenu>
</TableTitleActions>
</TableTitle>
<TableActions />
<SearchBar value={searchBarValue} onChange={handleSearchBarChange} />
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Job>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<TableContent
rows={page}
prepareRow={prepareRow}
isLoading={isLoading}
emptyContent="No jobs found"
renderRow={(row, { key, className, role, style }) => (
<Fragment key={key}>
<TableRow<Job>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
{row.isExpanded && (
<tr>
<td />
<td colSpan={row.cells.length - 1}>
<TasksDatatable data={row.original.Tasks} />
</td>
</tr>
)}
</Fragment>
)}
/>
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={jobs.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
<ExpandableDatatable<Job>
dataset={jobs}
columns={columns}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
title="Nomad Jobs"
titleIcon={Clock}
disableSelect
emptyContentLabel="No jobs found"
renderSubRow={(row) => <TasksDatatable data={row.original.Tasks} />}
isLoading={isLoading}
renderTableSettings={() => (
<TableSettingsMenu>
<JobsDatatableSettings settings={settings} />
</TableSettingsMenu>
)}
/>
);
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
}

View File

@ -1,11 +1,12 @@
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { JobsTableSettings } from './types';
import { TableSettings } from './types';
export function JobsDatatableSettings() {
const { settings, setTableSettings } = useTableSettings<JobsTableSettings>();
interface Props {
settings: TableSettings;
}
export function JobsDatatableSettings({ settings }: Props) {
return (
<TableSettingsMenuAutoRefresh
value={settings.autoRefreshRate}
@ -14,6 +15,6 @@ export function JobsDatatableSettings() {
);
function handleRefreshRateChange(autoRefreshRate: number) {
setTableSettings({ autoRefreshRate });
settings.setAutoRefreshRate(autoRefreshRate);
}
}

View File

@ -1,97 +1,21 @@
import { useFilters, usePagination, useSortBy, useTable } from 'react-table';
import { useState } from 'react';
import { Task } from '@/react/nomad/types';
import { Table, TableContainer, TableHeaderRow, TableRow } from '@@/datatables';
import { InnerDatatable } from '@@/datatables/InnerDatatable';
import { NestedDatatable } from '@@/datatables/NestedDatatable';
import { useColumns } from './columns';
export interface TasksTableProps {
export interface Props {
data: Task[];
}
export function TasksDatatable({ data }: TasksTableProps) {
export function TasksDatatable({ data }: Props) {
const columns = useColumns();
const [sortBy, setSortBy] = useState({ id: 'taskName', desc: false });
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
useTable<Task>(
{
columns,
data,
initialState: {
sortBy: [sortBy],
},
},
useFilters,
useSortBy,
usePagination
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return (
<InnerDatatable>
<TableContainer>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Task>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{data.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<TableRow<Task>
key={key}
cells={row.cells}
className={className}
role={role}
style={style}
/>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
no tasks
</td>
</tr>
)}
</tbody>
</Table>
</TableContainer>
</InnerDatatable>
<NestedDatatable
columns={columns}
dataset={data}
defaultSortBy="taskName"
/>
);
function handleSortChange(id: string, desc: boolean) {
setSortBy({ id, desc });
}
}

View File

@ -3,6 +3,7 @@ import { CellProps, Column } from 'react-table';
import { Task } from '@/react/nomad/types';
import { Link } from '@@/Link';
import { Icon } from '@@/Icon';
export const actions: Column<Task> = {
Header: 'Task Actions',
@ -25,7 +26,7 @@ export function ActionsCell({ row }: CellProps<Task>) {
};
return (
<div className="text-center">
<div className="text-center vertical-center">
{/* events */}
<Link
to="nomad.events"
@ -33,12 +34,12 @@ export function ActionsCell({ row }: CellProps<Task>) {
title="Events"
className="space-right"
>
<i className="fa fa-history space-right" aria-hidden="true" />
<Icon icon="clock" feather className="space-right icon" />
</Link>
{/* logs */}
<Link to="nomad.logs" params={params} title="Logs">
<i className="fa fa-file-alt space-right" aria-hidden="true" />
<Icon icon="file-text" feather className="space-right icon" />
</Link>
</div>
);

View File

@ -1,8 +1,7 @@
import * as notifications from '@/portainer/services/notifications';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Job } from '@/react/nomad/types';
import { deleteJob } from '../../../jobs.service';
import { deleteJob } from '@/react/nomad/jobs/jobs.service';
export async function deleteJobs(environmentID: EnvironmentId, jobs: Job[]) {
return Promise.all(

View File

@ -1,4 +1,5 @@
import { CellProps, Column } from 'react-table';
import { Clock } from 'react-feather';
import { Job } from '@/react/nomad/types';
@ -18,7 +19,7 @@ export function ActionsCell({ row }: CellProps<Job>) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<div className="text-center" {...row.getToggleRowExpandedProps()}>
<i className="fa fa-history space-right" aria-hidden="true" />
<Clock className="feather" />
</div>
);
}

View File

@ -1,11 +1,7 @@
import { useMemo } from 'react';
import { name } from './name';
import { status } from './status';
import { created } from './created';
import { actions } from './actions';
import { namespace } from './namespace';
export function useColumns() {
return useMemo(() => [name, status, namespace, actions, created], []);
}
export const columns = [name, status, namespace, actions, created];

View File

@ -0,0 +1,13 @@
import { refreshableSettings, createPersistedStore } from '@@/datatables/types';
import { TableSettings } from './types';
export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(
storageKey,
'SubmitTime',
(set) => ({
...refreshableSettings(set),
})
);
}

View File

@ -1,5 +1,8 @@
export interface JobsTableSettings {
autoRefreshRate: number;
pageSize: number;
sortBy: { id: string; desc: boolean };
}
import {
BasicTableSettings,
RefreshableTableSettings,
} from '@@/datatables/types';
export interface TableSettings
extends BasicTableSettings,
RefreshableTableSettings {}

View File

@ -1,7 +1,6 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { PageHeader } from '@@/PageHeader';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import { useJobs } from './useJobs';
import { JobsDatatable } from './JobsDatatable';
@ -10,12 +9,6 @@ export function JobsView() {
const environmentId = useEnvironmentId();
const jobsQuery = useJobs(environmentId);
const defaultSettings = {
autoRefreshRate: 10,
pageSize: 10,
sortBy: { id: 'name', desc: false },
};
async function reloadData() {
await jobsQuery.refetch();
}
@ -30,17 +23,11 @@ export function JobsView() {
onReload={reloadData}
/>
<div className="row">
<div className="col-sm-12">
<TableSettingsProvider defaults={defaultSettings} storageKey="jobs">
<JobsDatatable
jobs={jobsQuery.data || []}
refreshData={reloadData}
isLoading={jobsQuery.isLoading}
/>
</TableSettingsProvider>
</div>
</div>
<JobsDatatable
jobs={jobsQuery.data || []}
refreshData={reloadData}
isLoading={jobsQuery.isLoading}
/>
</>
);
}

View File

@ -175,159 +175,156 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
return (
<>
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
<div className="row">
<div className="col-sm-12">
<TableContainer>
<TableTitle icon="hard-drive" featherIcon label="Environments" />
<TableActions className={styles.actionBar}>
<div className={styles.description}>
Click on an environment to manage
</div>
<div className={styles.actionButton}>
<div className={styles.refreshButton}>
{isAdmin && (
<Button
onClick={onRefresh}
data-cy="home-refreshEndpointsButton"
size="medium"
color="secondary"
className={clsx(
'vertical-center !ml-0',
styles.refreshEnvironmentsButton
)}
>
<RefreshCcw
className="feather icon-sm icon-white"
aria-hidden="true"
/>
Refresh
</Button>
<TableContainer>
<TableTitle icon="hard-drive" featherIcon label="Environments" />
<TableActions className={styles.actionBar}>
<div className={styles.description}>
Click on an environment to manage
</div>
<div className={styles.actionButton}>
<div className={styles.refreshButton}>
{isAdmin && (
<Button
onClick={onRefresh}
data-cy="home-refreshEndpointsButton"
size="medium"
color="secondary"
className={clsx(
'vertical-center !ml-0',
styles.refreshEnvironmentsButton
)}
</div>
<div className={styles.kubeconfigButton}>
<KubeconfigButton
environments={environments}
envQueryParams={{
...environmentsQueryParams,
sort: sortByFilter,
order: sortByDescending ? 'desc' : 'asc',
}}
>
<RefreshCcw
className="feather icon-sm icon-white"
aria-hidden="true"
/>
</div>
<div className={clsx(styles.filterSearchbar, 'ml-3')}>
<FilterSearchBar
value={searchBarValue}
onChange={setSearchBarValue}
placeholder="Search by name, group, tag, status, URL..."
data-cy="home-endpointsSearchInput"
/>
</div>
</div>
</TableActions>
<div className={styles.filterContainer}>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={platformTypeOptions}
onChange={setPlatformTypes}
placeHolder="Platform"
value={platformTypes}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={connectionTypeOptions}
onChange={setConnectionTypes}
placeHolder="Connection Type"
value={connectionTypes}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={status}
onChange={statusOnChange}
placeHolder="Status"
value={statusState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={uniqueTag}
onChange={tagOnChange}
placeHolder="Tags"
value={tagState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={uniqueGroup}
onChange={groupOnChange}
placeHolder="Groups"
value={groupState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter<string>
filterOptions={
agentVersionsQuery.data?.map((v) => ({
label: v,
value: v,
})) || []
}
onChange={setAgentVersions}
placeHolder="Agent Version"
value={agentVersions}
/>
</div>
<button
type="button"
className={styles.clearButton}
onClick={clearFilter}
>
Clear all
</button>
<div className={styles.filterRight}>
<SortbySelector
filterOptions={sortByOptions}
onChange={sortOnchange}
onDescending={sortOnDescending}
placeHolder="Sort By"
sortByDescending={sortByDescending}
sortByButton={sortByButton}
value={sortByState}
/>
</div>
</div>
<div className="blocklist" data-cy="home-endpointList">
{renderItems(
isLoading,
totalCount,
environments.map((env) => (
<EnvironmentItem
key={env.Id}
environment={env}
groupName={
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
}
onClick={onClickItem}
/>
))
Refresh
</Button>
)}
</div>
<TableFooter>
<PaginationControls
showAll={totalCount <= 100}
pageLimit={pageLimit}
page={page}
onPageChange={setPage}
totalCount={totalCount}
onPageLimitChange={setPageLimit}
<div className={styles.kubeconfigButton}>
<KubeconfigButton
environments={environments}
envQueryParams={{
...environmentsQueryParams,
sort: sortByFilter,
order: sortByDescending ? 'desc' : 'asc',
}}
/>
</TableFooter>
</TableContainer>
</div>
<div className={clsx(styles.filterSearchbar, 'ml-3')}>
<FilterSearchBar
value={searchBarValue}
onChange={setSearchBarValue}
placeholder="Search by name, group, tag, status, URL..."
data-cy="home-endpointsSearchInput"
/>
</div>
</div>
</TableActions>
<div className={styles.filterContainer}>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={platformTypeOptions}
onChange={setPlatformTypes}
placeHolder="Platform"
value={platformTypes}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={connectionTypeOptions}
onChange={setConnectionTypes}
placeHolder="Connection Type"
value={connectionTypes}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={status}
onChange={statusOnChange}
placeHolder="Status"
value={statusState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={uniqueTag}
onChange={tagOnChange}
placeHolder="Tags"
value={tagState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter
filterOptions={uniqueGroup}
onChange={groupOnChange}
placeHolder="Groups"
value={groupState}
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter<string>
filterOptions={
agentVersionsQuery.data?.map((v) => ({
label: v,
value: v,
})) || []
}
onChange={setAgentVersions}
placeHolder="Agent Version"
value={agentVersions}
/>
</div>
<button
type="button"
className={styles.clearButton}
onClick={clearFilter}
>
Clear all
</button>
<div className={styles.filterRight}>
<SortbySelector
filterOptions={sortByOptions}
onChange={sortOnchange}
onDescending={sortOnDescending}
placeHolder="Sort By"
sortByDescending={sortByDescending}
sortByButton={sortByButton}
value={sortByState}
/>
</div>
</div>
</div>
<div className="blocklist" data-cy="home-endpointList">
{renderItems(
isLoading,
totalCount,
environments.map((env) => (
<EnvironmentItem
key={env.Id}
environment={env}
groupName={
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
}
onClick={onClickItem}
/>
))
)}
</div>
<TableFooter>
<PaginationControls
showAll={totalCount <= 100}
pageLimit={pageLimit}
page={page}
onPageChange={setPage}
totalCount={totalCount}
onPageLimitChange={setPageLimit}
/>
</TableFooter>
</TableContainer>
</>
);

View File

@ -46,39 +46,35 @@ export function AccessControlPanel({
(!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams);
return (
<div className="row">
<div className="col-sm-12">
<TableContainer>
<TableTitle label="Access control" icon="eye" featherIcon />
<AccessControlPanelDetails
resourceType={resourceType}
resourceControl={resourceControl}
/>
<TableContainer>
<TableTitle label="Access control" icon="eye" featherIcon />
<AccessControlPanelDetails
resourceType={resourceType}
resourceControl={resourceControl}
/>
{!isEditDisabled && !isEditMode && (
<div className="row">
<div>
<Button color="link" onClick={toggleEditMode}>
<Icon icon="edit" className="space-right" feather />
Change ownership
</Button>
</div>
</div>
)}
{!isEditDisabled && !isEditMode && (
<div className="row">
<div>
<Button color="link" onClick={toggleEditMode}>
<Icon icon="edit" className="space-right" feather />
Change ownership
</Button>
</div>
</div>
)}
{isEditMode && (
<AccessControlPanelForm
resourceControl={resourceControl}
onCancelClick={() => toggleEditMode()}
resourceId={resourceId}
resourceType={resourceType}
environmentId={environmentId}
onUpdateSuccess={handleUpdateSuccess}
/>
)}
</TableContainer>
</div>
</div>
{isEditMode && (
<AccessControlPanelForm
resourceControl={resourceControl}
onCancelClick={() => toggleEditMode()}
resourceId={resourceId}
resourceType={resourceType}
environmentId={environmentId}
onUpdateSuccess={handleUpdateSuccess}
/>
)}
</TableContainer>
);
async function handleUpdateSuccess() {

View File

@ -1,4 +1,5 @@
import { Clock, Trash2 } from 'react-feather';
import { useStore } from 'zustand';
import {
FeatureFlag,
@ -11,6 +12,7 @@ import { Datatable } from '@@/datatables';
import { PageHeader } from '@@/PageHeader';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { useList } from '../queries/list';
import { EdgeUpdateSchedule } from '../types';
@ -20,12 +22,15 @@ import { columns } from './columns';
import { createStore } from './datatable-store';
const storageKey = 'update-schedules-list';
const useStore = createStore(storageKey);
const settingsStore = createStore(storageKey);
export function ListView() {
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const listQuery = useList();
const store = useStore();
if (!listQuery.data) {
return null;
@ -40,20 +45,22 @@ export function ListView() {
/>
<Datatable
columns={columns}
titleOptions={{
title: 'Update & rollback',
icon: Clock,
}}
dataset={listQuery.data}
settingsStore={store}
storageKey={storageKey}
columns={columns}
title="Update & rollback"
titleIcon={Clock}
emptyContentLabel="No schedules found"
isLoading={listQuery.isLoading}
totalCount={listQuery.data.length}
renderTableActions={(selectedRows) => (
<TableActions selectedRows={selectedRows} />
)}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
/>
</>
);

View File

@ -1,36 +1,25 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
refreshableSettings,
hiddenColumnsSettings,
PaginationTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
createPersistedStore,
BasicTableSettings,
} from '@/react/components/datatables/types';
interface TableSettings
extends SortableTableSettings,
PaginationTableSettings,
extends BasicTableSettings,
SettableColumnsTableSettings,
RefreshableTableSettings {}
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
...hiddenColumnsSettings(set),
...refreshableSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
return createPersistedStore<TableSettings>(
storageKey,
'time',
(set) => ({
...hiddenColumnsSettings(set),
...refreshableSettings(set),
})
);
}

View File

@ -11,17 +11,20 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { PageHeader } from '@@/PageHeader';
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { notificationsStore } from './notifications-store';
import { ToastNotification } from './types';
import { columns } from './columns';
import { createStore } from './datatable-store';
const storageKey = 'notifications-list';
const useSettingsStore = createStore(storageKey, 'time', true);
const settingsStore = createPersistedStore(storageKey, {
id: 'time',
desc: true,
});
export function NotificationsView() {
const settingsStore = useSettingsStore();
const { user } = useUser();
const userNotifications: ToastNotification[] =
@ -29,9 +32,11 @@ export function NotificationsView() {
[];
const breadcrumbs = 'Notifications';
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const {
params: { id },
params: { id: activeItemId },
} = useCurrentStateAndParams();
return (
@ -39,19 +44,22 @@ export function NotificationsView() {
<PageHeader title="Notifications" breadcrumbs={breadcrumbs} reload />
<Datatable
columns={columns}
titleOptions={{
title: 'Notifications',
icon: Bell,
}}
title="Notifications"
titleIcon={Bell}
dataset={userNotifications}
settingsStore={settingsStore}
storageKey="notifications"
emptyContentLabel="No notifications found"
totalCount={userNotifications.length}
renderTableActions={(selectedRows) => (
<TableActions selectedRows={selectedRows} />
)}
initialActiveItem={id}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
getRowId={(row) => row.id}
highlightedItemId={activeItemId}
/>
</>
);

View File

@ -1,40 +0,0 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
refreshableSettings,
hiddenColumnsSettings,
PaginationTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
interface TableSettings
extends SortableTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings,
RefreshableTableSettings {}
export function createStore(
storageKey: string,
initialSortBy?: string,
desc?: boolean
) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set, initialSortBy, desc),
...paginationSettings(set),
...hiddenColumnsSettings(set),
...refreshableSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View File

@ -1,33 +1,17 @@
import { useTable, usePagination, useSortBy } from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { List } from 'react-feather';
import { useStore } from 'zustand';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
import PortainerError from '@/portainer/error';
import { Datatable } from '@@/datatables';
import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { PaginationControls } from '@@/PaginationControls';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { TableFooter } from '@@/datatables/TableFooter';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { useRowSelect } from '@@/datatables/useRowSelect';
import {
Table,
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
} from '@@/datatables';
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
import { useFDOProfiles } from './useFDOProfiles';
import { useColumns } from './columns';
import { FDOProfilesDatatableActions } from './FDOProfilesDatatableActions';
import { useFDOProfiles } from './useFDOProfiles';
export interface FDOProfilesTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
const storageKey = 'fdoProfiles';
const settingsStore = createPersistedStore(storageKey, 'name');
export interface FDOProfilesDatatableProps {
isFDOEnabled: boolean;
@ -36,132 +20,33 @@ export interface FDOProfilesDatatableProps {
export function FDOProfilesDatatable({
isFDOEnabled,
}: FDOProfilesDatatableProps) {
const { settings, setTableSettings } =
useTableSettings<FDOProfilesTableSettings>();
const columns = useColumns();
const { isLoading, profiles, error } = useFDOProfiles();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
state: { pageIndex, pageSize },
} = useTable<Profile>(
{
defaultCanFilter: false,
columns,
data: profiles,
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
},
isRowSelectable() {
return isFDOEnabled;
},
selectColumnWidth: 5,
},
useSortBy,
usePagination,
useRowSelect,
useRowSelectColumn
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const { isLoading, profiles } = useFDOProfiles();
return (
<TableContainer>
<TableTitle icon="list" featherIcon label="Device Profiles">
<Datatable
columns={columns}
dataset={profiles}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
title="Device Profiles"
titleIcon={List}
disableSelect={!isFDOEnabled}
emptyContentLabel="No profiles found"
getRowId={(row) => row.id.toString()}
isLoading={isLoading}
renderTableActions={(selectedItems) => (
<FDOProfilesDatatableActions
isFDOEnabled={isFDOEnabled}
selectedItems={selectedFlatRows.map((row) => row.original)}
selectedItems={selectedItems}
/>
</TableTitle>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Profile>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{!isLoading && profiles && profiles.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<TableRow<Profile>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
{userMessage(isLoading, error)}
</td>
</tr>
)}
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={profiles ? profiles.length : 0}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
)}
/>
);
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
}
function userMessage(isLoading: boolean, error?: PortainerError) {
if (isLoading) {
return 'Loading...';
}
if (error) {
return error.message;
}
return 'No profiles found';
}

View File

@ -1,22 +0,0 @@
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import {
FDOProfilesDatatable,
FDOProfilesDatatableProps,
} from './FDOProfilesDatatable';
export function FDOProfilesDatatableContainer({
...props
}: FDOProfilesDatatableProps) {
const defaultSettings = {
pageSize: 10,
sortBy: { id: 'name', desc: false },
};
return (
<TableSettingsProvider defaults={defaultSettings} storageKey="fdoProfiles">
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FDOProfilesDatatable {...props} />
</TableSettingsProvider>
);
}

View File

@ -0,0 +1 @@
export { FDOProfilesDatatable } from './FDOProfilesDatatable';

View File

@ -11,7 +11,7 @@ import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip';
import { Input } from '@@/form-components/Input';
import { FDOProfilesDatatableContainer } from '../FDOProfilesDatatable/FDOProfilesDatatableContainer';
import { FDOProfilesDatatable } from '../FDOProfilesDatatable';
import styles from './SettingsFDO.module.css';
import { validationSchema } from './SettingsFDO.validation';
@ -165,7 +165,7 @@ export function SettingsFDO({ settings, onSubmit }: Props) {
Add, Edit and Manage the list of device profiles available
during FDO device setup
</TextTip>
<FDOProfilesDatatableContainer isFDOEnabled={initialFDOEnabled} />
<FDOProfilesDatatable isFDOEnabled={initialFDOEnabled} />
</div>
)}
</WidgetBody>

View File

@ -6,7 +6,7 @@ import { PageHeader } from '@@/PageHeader';
import { useTeams } from '../queries';
import { CreateTeamForm } from './CreateTeamForm';
import { TeamsDatatableContainer } from './TeamsDatatable/TeamsDatatable';
import { TeamsDatatable } from './TeamsDatatable';
export function ListView() {
const { isAdmin } = useUser();
@ -23,7 +23,7 @@ export function ListView() {
)}
{teamsQuery.data && (
<TeamsDatatableContainer teams={teamsQuery.data} isAdmin={isAdmin} />
<TeamsDatatable teams={teamsQuery.data} isAdmin={isAdmin} />
)}
</>
);

View File

@ -1,14 +1,7 @@
import { useRowSelectColumn } from '@lineup-lite/hooks';
import {
Column,
useGlobalFilter,
usePagination,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
import { Column } from 'react-table';
import { useMutation, useQueryClient } from 'react-query';
import { Trash2, Users } from 'react-feather';
import { useStore } from 'zustand';
import { notifySuccess } from '@/portainer/services/notifications';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
@ -16,23 +9,13 @@ import { Team, TeamId } from '@/react/portainer/users/teams/types';
import { deleteTeam } from '@/react/portainer/users/teams/teams.service';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { PaginationControls } from '@@/PaginationControls';
import { Checkbox } from '@@/form-components/Checkbox';
import { Table } from '@@/datatables';
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { TableFooter } from '@@/datatables/TableFooter';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import {
TableSettingsProvider,
useTableSettings,
} from '@@/datatables/useTableSettings';
import { TableContent } from '@@/datatables/TableContent';
import { buildNameColumn } from '@@/datatables/NameCell';
import { createPersistedStore } from '@@/datatables/types';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { TableSettings } from './types';
const tableKey = 'teams';
const storageKey = 'teams';
const columns: readonly Column<Team>[] = [
buildNameColumn('Name', 'Id', 'portainer.teams.team'),
@ -43,168 +26,47 @@ interface Props {
isAdmin: boolean;
}
const settingsStore = createPersistedStore(storageKey);
export function TeamsDatatable({ teams, isAdmin }: Props) {
const { handleRemove } = useRemoveMutation();
const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey);
const { settings, setTableSettings } = useTableSettings<TableSettings>();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<Team>(
{
defaultCanFilter: false,
columns,
data: teams,
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
selectCheckboxComponent: Checkbox,
},
useGlobalFilter,
useSortBy,
usePagination,
useRowSelect,
isAdmin ? useRowSelectColumn : emptyPlugin
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
return (
<div className="row">
<div className="col-sm-12">
<Table.Container>
<Table.Title icon={Users} label="Teams">
<SearchBar
value={searchBarValue}
onChange={handleSearchBarChange}
/>
{isAdmin && (
<Table.Actions>
<Button
color="dangerlight"
onClick={handleRemoveClick}
disabled={selectedFlatRows.length === 0}
icon={Trash2}
>
Remove
</Button>
</Table.Actions>
)}
</Table.Title>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
<Datatable
dataset={teams}
columns={columns}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
title="Teams"
titleIcon={Users}
renderTableActions={(selectedRows) =>
isAdmin && (
<Button
color="dangerlight"
onClick={() => handleRemoveClick(selectedRows)}
disabled={selectedRows.length === 0}
icon={Trash2}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<Table.HeaderRow<Team>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<TableContent
prepareRow={prepareRow}
renderRow={(row, { key, className, role, style }) => (
<Table.Row<Team>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
rows={page}
emptyContent="No teams found"
/>
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={teams.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</Table.Container>
</div>
</div>
Remove
</Button>
)
}
emptyContentLabel="No teams found"
/>
);
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings({ pageSize });
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
setGlobalFilter(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings({ sortBy: { id, desc } });
}
function handleRemoveClick() {
const ids = selectedFlatRows.map((row) => row.original.Id);
function handleRemoveClick(selectedRows: Team[]) {
const ids = selectedRows.map((row) => row.Id);
handleRemove(ids);
}
}
const defaultSettings: TableSettings = {
pageSize: 10,
sortBy: { id: 'name', desc: false },
};
export function TeamsDatatableContainer(props: Props) {
return (
<TableSettingsProvider<TableSettings>
defaults={defaultSettings}
storageKey={tableKey}
>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<TeamsDatatable {...props} />
</TableSettingsProvider>
);
}
function useRemoveMutation() {
const queryClient = useQueryClient();
@ -239,6 +101,3 @@ function useRemoveMutation() {
});
}
}
function emptyPlugin() {}
emptyPlugin.pluginName = 'emptyPlugin';

View File

@ -1,8 +0,0 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
export interface TableSettings
extends PaginationTableSettings,
SortableTableSettings {}

View File

@ -30,6 +30,8 @@ const licenseInfo: LicenseInfo = {
expiresAt: Number.MAX_SAFE_INTEGER,
productEdition: Edition.EE,
valid: true,
enforcedAt: 0,
enforced: false,
};
export const handlers = [

View File

@ -143,7 +143,7 @@
"xterm": "^3.8.0",
"yaml": "^1.10.2",
"yup": "^0.32.11",
"zustand": "^4.0.0"
"zustand": "^4.1.1"
},
"devDependencies": {
"@apidevtools/swagger-cli": "^4.0.4",

View File

@ -19129,10 +19129,10 @@ z-schema@^5.0.1:
optionalDependencies:
commander "^2.7.1"
zustand@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.0.0.tgz#739cba69209ffe67b31e7d6741c25b51496114a7"
integrity sha512-OrsfQTnRXF1LZ9/vR/IqN9ws5EXUhb149xmPjErZnUrkgxS/gAHGy2dPNIVkVvoxrVe1sIydn4JjF0dYHmGeeQ==
zustand@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.1.1.tgz#5a61cc755a002df5f041840a414ae6e9a99ee22b"
integrity sha512-h4F3WMqsZgvvaE0n3lThx4MM81Ls9xebjvrABNzf5+jb3/03YjNTSgZXeyrvXDArMeV9untvWXRw1tY+ntPYbA==
dependencies:
use-sync-external-store "1.2.0"