mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
refactor(docker/images): convert table to react [EE-4668] (#8910)
This commit is contained in:
parent
0e9902fee9
commit
ecd54ab929
26 changed files with 496 additions and 441 deletions
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AriaAttributes,
|
||||
ComponentType,
|
||||
forwardRef,
|
||||
MouseEventHandler,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
|
@ -39,9 +40,17 @@ export interface Props<TasProps = unknown>
|
|||
type?: Type;
|
||||
as?: ComponentType<TasProps> | string;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
mRef?: React.ForwardedRef<HTMLButtonElement>;
|
||||
props?: TasProps;
|
||||
}
|
||||
|
||||
export const ButtonWithRef = forwardRef<HTMLButtonElement, Omit<Props, 'mRef'>>(
|
||||
(props, ref) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<Button {...props} mRef={ref} />
|
||||
)
|
||||
);
|
||||
|
||||
export function Button<TasProps = unknown>({
|
||||
type = 'button',
|
||||
color = 'primary',
|
||||
|
@ -54,15 +63,19 @@ export function Button<TasProps = unknown>({
|
|||
children,
|
||||
as = 'button',
|
||||
props,
|
||||
mRef,
|
||||
...ariaProps
|
||||
}: PropsWithChildren<Props<TasProps>>) {
|
||||
const Component = as as 'button';
|
||||
return (
|
||||
<Component
|
||||
ref={mRef}
|
||||
/* eslint-disable-next-line react/button-has-type */
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
className={clsx(`btn btn-${color}`, sizeClass(size), className)}
|
||||
className={clsx(`btn btn-${color}`, sizeClass(size), className, {
|
||||
disabled,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
if (!disabled) {
|
||||
onClick?.(e);
|
||||
|
|
|
@ -4,6 +4,8 @@ import { Menu, MenuButton, MenuPopover } from '@reach/menu-button';
|
|||
import { Column } from '@tanstack/react-table';
|
||||
import { Check, Filter } from 'lucide-react';
|
||||
|
||||
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
interface MultipleSelectionFilterProps {
|
||||
|
@ -103,19 +105,3 @@ export function filterHOC<TData extends Record<string, unknown>>(
|
|||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getValueAsArrayOfStrings(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!value || (typeof value !== 'string' && typeof value !== 'number')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return [value.toString()];
|
||||
}
|
||||
|
||||
return [value];
|
||||
}
|
||||
|
|
|
@ -93,8 +93,23 @@ export function createPersistedStore<T extends BasicTableSettings>(
|
|||
...create(set),
|
||||
} as T),
|
||||
{
|
||||
name: keyBuilder(storageKey),
|
||||
name: `datatable_settings_${keyBuilder(storageKey)}`,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** this class is just a dummy class to get return type of createPersistedStore
|
||||
* can be fixed after upgrade to ts 4.7+
|
||||
* https://stackoverflow.com/a/64919133
|
||||
*/
|
||||
class Wrapper<T extends BasicTableSettings> {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
wrapped() {
|
||||
return createPersistedStore<T>('', '');
|
||||
}
|
||||
}
|
||||
|
||||
export type CreatePersistedStoreReturn<
|
||||
T extends BasicTableSettings = BasicTableSettings
|
||||
> = ReturnType<Wrapper<T>['wrapped']>;
|
||||
|
|
|
@ -2,22 +2,11 @@ import { useMemo, useState } from 'react';
|
|||
import { useStore } from 'zustand';
|
||||
|
||||
import { useSearchBarState } from './SearchBar';
|
||||
import { BasicTableSettings, createPersistedStore } from './types';
|
||||
|
||||
/** this class is just a dummy class to get return type of createPersistedStore
|
||||
* can be fixed after upgrade to ts 4.7+
|
||||
* https://stackoverflow.com/a/64919133
|
||||
*/
|
||||
class Wrapper<T extends BasicTableSettings> {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
wrapped() {
|
||||
return createPersistedStore<T>('', '');
|
||||
}
|
||||
}
|
||||
import { BasicTableSettings, CreatePersistedStoreReturn } from './types';
|
||||
|
||||
export function useTableState<
|
||||
TSettings extends BasicTableSettings = BasicTableSettings
|
||||
>(store: ReturnType<Wrapper<TSettings>['wrapped']>, storageKey: string) {
|
||||
>(store: CreatePersistedStoreReturn<TSettings>, storageKey: string) {
|
||||
const settings = useStore(store);
|
||||
|
||||
const [search, setSearch] = useSearchBarState(storageKey);
|
||||
|
|
|
@ -0,0 +1,213 @@
|
|||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
List,
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Menu, MenuButton, MenuItem, MenuPopover } from '@reach/menu-button';
|
||||
import { positionRight } from '@reach/popover';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import {
|
||||
BasicTableSettings,
|
||||
createPersistedStore,
|
||||
refreshableSettings,
|
||||
RefreshableTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { Button, ButtonGroup, LoadingButton } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
import { ButtonWithRef } from '@@/buttons/Button';
|
||||
import { useRepeater } from '@@/datatables/useRepeater';
|
||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
|
||||
import { DockerImage } from '../../types';
|
||||
|
||||
import { columns as defColumns } from './columns';
|
||||
import { host as hostColumn } from './columns/host';
|
||||
import { RowProvider } from './RowContext';
|
||||
|
||||
const tableKey = 'images';
|
||||
|
||||
export interface TableSettings
|
||||
extends BasicTableSettings,
|
||||
RefreshableTableSettings {}
|
||||
|
||||
const settingsStore = createPersistedStore<TableSettings>(
|
||||
tableKey,
|
||||
'tags',
|
||||
(set) => ({
|
||||
...refreshableSettings(set),
|
||||
})
|
||||
);
|
||||
|
||||
export function ImagesDatatable({
|
||||
dataset,
|
||||
|
||||
environment,
|
||||
isHostColumnVisible,
|
||||
isExportInProgress,
|
||||
onDownload,
|
||||
onRefresh,
|
||||
onRemove,
|
||||
}: {
|
||||
dataset: Array<DockerImage>;
|
||||
environment: Environment;
|
||||
isHostColumnVisible: boolean;
|
||||
|
||||
onDownload: (images: Array<DockerImage>) => void;
|
||||
onRemove: (images: Array<DockerImage>, force: true) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
isExportInProgress: boolean;
|
||||
}) {
|
||||
const tableState = useTableState(settingsStore, tableKey);
|
||||
const columns = useMemo(
|
||||
() => (isHostColumnVisible ? [...defColumns, hostColumn] : defColumns),
|
||||
[isHostColumnVisible]
|
||||
);
|
||||
|
||||
useRepeater(tableState.autoRefreshRate, onRefresh);
|
||||
|
||||
return (
|
||||
<RowProvider context={{ environment }}>
|
||||
<Datatable
|
||||
title="Images"
|
||||
titleIcon={List}
|
||||
renderTableActions={(selectedItems) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<RemoveButtonMenu
|
||||
selectedItems={selectedItems}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
|
||||
<ImportExportButtons
|
||||
isExportInProgress={isExportInProgress}
|
||||
onExportClick={onDownload}
|
||||
selectedItems={selectedItems}
|
||||
/>
|
||||
|
||||
<Authorized authorizations="DockerImageBuild">
|
||||
<Button
|
||||
as={Link}
|
||||
props={{ to: 'docker.images.build' }}
|
||||
data-cy="image-buildImageButton"
|
||||
icon={Plus}
|
||||
>
|
||||
Build a new image
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
)}
|
||||
dataset={dataset}
|
||||
settingsManager={tableState}
|
||||
columns={columns}
|
||||
emptyContentLabel="No images found"
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<TableSettingsMenuAutoRefresh
|
||||
value={tableState.autoRefreshRate}
|
||||
onChange={(value) => tableState.setAutoRefreshRate(value)}
|
||||
/>
|
||||
</TableSettingsMenu>
|
||||
)}
|
||||
/>
|
||||
</RowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveButtonMenu({
|
||||
onRemove,
|
||||
selectedItems,
|
||||
}: {
|
||||
selectedItems: Array<DockerImage>;
|
||||
onRemove(selectedItems: Array<DockerImage>, force: boolean): void;
|
||||
}) {
|
||||
return (
|
||||
<Authorized authorizations="DockerImageDelete">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
size="small"
|
||||
color="dangerlight"
|
||||
icon={Trash2}
|
||||
disabled={selectedItems.length === 0}
|
||||
data-cy="image-removeImageButton"
|
||||
onClick={() => {
|
||||
onRemove(selectedItems, false);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={ButtonWithRef}
|
||||
size="small"
|
||||
color="dangerlight"
|
||||
disabled={selectedItems.length === 0}
|
||||
icon={ChevronDown}
|
||||
>
|
||||
<span className="sr-only">Toggle Dropdown</span>
|
||||
</MenuButton>
|
||||
<MenuPopover position={positionRight}>
|
||||
<div className="mt-3 bg-white">
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
onRemove(selectedItems, true);
|
||||
}}
|
||||
>
|
||||
Force Remove
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
</ButtonGroup>
|
||||
</Authorized>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportExportButtons({
|
||||
isExportInProgress,
|
||||
selectedItems,
|
||||
onExportClick,
|
||||
}: {
|
||||
isExportInProgress: boolean;
|
||||
selectedItems: Array<DockerImage>;
|
||||
onExportClick(selectedItems: Array<DockerImage>): void;
|
||||
}) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Authorized authorizations="DockerImageLoad">
|
||||
<Button
|
||||
size="small"
|
||||
color="light"
|
||||
as={Link}
|
||||
data-cy="image-importImageButton"
|
||||
icon={Upload}
|
||||
disabled={isExportInProgress}
|
||||
props={{ to: 'docker.images.import' }}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</Authorized>
|
||||
<Authorized authorizations="DockerImageGet">
|
||||
<LoadingButton
|
||||
size="small"
|
||||
color="light"
|
||||
icon={Download}
|
||||
isLoading={isExportInProgress}
|
||||
loadingText="Export in progress..."
|
||||
data-cy="image-exportImageButton"
|
||||
onClick={() => onExportClick(selectedItems)}
|
||||
disabled={selectedItems.length === 0}
|
||||
>
|
||||
Export
|
||||
</LoadingButton>
|
||||
</Authorized>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { createRowContext } from '@@/datatables/RowContext';
|
||||
|
||||
interface RowContextState {
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
const { RowProvider, useRowContext } = createRowContext<RowContextState>();
|
||||
|
||||
export { RowProvider, useRowContext };
|
|
@ -0,0 +1,12 @@
|
|||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const created = columnHelper.accessor('Created', {
|
||||
id: 'created',
|
||||
header: 'Created',
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return isoDateFromTimestamp(value);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { DockerImage } from '@/react/docker/images/types';
|
||||
|
||||
export const columnHelper = createColumnHelper<
|
||||
DockerImage & { NodeName?: string }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Docker response from proxy (with added portainer metadata)
|
||||
* images view model
|
||||
* images snapshot
|
||||
* snapshots view model
|
||||
*
|
||||
*
|
||||
*/
|
|
@ -0,0 +1,9 @@
|
|||
import { columnHelper } from './helper';
|
||||
|
||||
export const host = columnHelper.accessor('NodeName', {
|
||||
header: 'Host',
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value || '-';
|
||||
},
|
||||
});
|
|
@ -0,0 +1,84 @@
|
|||
import { CellContext, Column } from '@tanstack/react-table';
|
||||
import { useSref } from '@uirouter/react';
|
||||
|
||||
import { DockerImage } from '@/react/docker/images/types';
|
||||
import { truncate } from '@/portainer/filters/filters';
|
||||
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
|
||||
|
||||
import { MultipleSelectionFilter } from '@@/datatables/Filter';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const id = columnHelper.accessor('Id', {
|
||||
id: 'id',
|
||||
header: 'Id',
|
||||
cell: Cell,
|
||||
enableColumnFilter: true,
|
||||
filterFn: (
|
||||
{ original: { Used } },
|
||||
columnId,
|
||||
filterValue: Array<'Used' | 'Unused'>
|
||||
) => {
|
||||
if (filterValue.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterValue.includes('Used') && Used) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterValue.includes('Unused') && !Used) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
meta: {
|
||||
filter: FilterByUsage,
|
||||
},
|
||||
});
|
||||
|
||||
function FilterByUsage<TData extends { Used: boolean }>({
|
||||
column: { getFilterValue, setFilterValue, id },
|
||||
}: {
|
||||
column: Column<TData>;
|
||||
}) {
|
||||
const options = ['Used', 'Unused'];
|
||||
|
||||
const value = getFilterValue();
|
||||
|
||||
const valueAsArray = getValueAsArrayOfStrings(value);
|
||||
|
||||
return (
|
||||
<MultipleSelectionFilter
|
||||
options={options}
|
||||
filterKey={id}
|
||||
value={valueAsArray}
|
||||
onChange={setFilterValue}
|
||||
menuTitle="Filter by usage"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({
|
||||
getValue,
|
||||
row: { original: image },
|
||||
}: CellContext<DockerImage, string>) {
|
||||
const name = getValue();
|
||||
|
||||
const linkProps = useSref('.image', {
|
||||
id: image.Id,
|
||||
imageId: image.Id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
|
||||
{truncate(name, 40)}
|
||||
</a>
|
||||
{!image.Used && (
|
||||
<span className="label label-warning image-tag ml-2">Unused</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { created } from './created';
|
||||
import { id } from './id';
|
||||
import { size } from './size';
|
||||
import { tags } from './tags';
|
||||
|
||||
export const columns = [id, tags, size, created];
|
|
@ -0,0 +1,12 @@
|
|||
import { humanize } from '@/portainer/filters/filters';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const size = columnHelper.accessor('VirtualSize', {
|
||||
id: 'size',
|
||||
header: 'Size',
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return humanize(value);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { DockerImage } from '@/react/docker/images/types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const tags = columnHelper.accessor('RepoTags', {
|
||||
id: 'tags',
|
||||
header: 'Tags',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({ getValue }: CellContext<DockerImage, string[]>) {
|
||||
const repoTags = getValue();
|
||||
|
||||
return (
|
||||
<>
|
||||
{repoTags.map((tag, idx) => (
|
||||
<span key={idx} className="label label-primary image-tag" title={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import { PortainerMetadata } from '../../types';
|
||||
|
||||
export type DockerImageResponse = {
|
||||
Containers: number;
|
||||
Created: number;
|
||||
|
@ -9,4 +11,5 @@ export type DockerImageResponse = {
|
|||
SharedSize: number;
|
||||
Size: number;
|
||||
VirtualSize: number;
|
||||
Portainer?: PortainerMetadata;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue