mirror of
https://github.com/portainer/portainer.git
synced 2025-07-25 00:09:40 +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
|
@ -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