Files

191 lines
4.8 KiB
TypeScript

import { CellContext, createColumnHelper } from '@tanstack/react-table';
import { Layers } from 'lucide-react';
import { useMemo, useState } from 'react';
import { humanize } from '@/portainer/filters/filters';
import { pluralize } from '@/portainer/helpers/strings';
import { Button } from '@@/buttons';
import { Datatable } from '@@/datatables';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Modal } from '@@/modals';
import { ModalBody } from '@@/modals/Modal/ModalBody';
import { NodeRowData } from '../types';
import { columnHelper } from './helper';
interface CachedImageRow {
id: string;
image: string;
aliases: string;
aliasesCount: number;
sizeBytes: number;
}
type NodeImage = NonNullable<
NonNullable<NodeRowData['status']>['images']
>[number];
const imageColumnHelper = createColumnHelper<CachedImageRow>();
const columns = [
imageColumnHelper.accessor('image', {
id: 'image',
header: 'Image',
meta: {
width: '70%',
},
cell: ({ getValue }) => {
const imageName = getValue();
return (
<span className="block truncate text-sm font-medium" title={imageName}>
{imageName}
</span>
);
},
}),
imageColumnHelper.accessor('aliases', {
id: 'aliases',
header: 'Aliases',
sortingFn: (left, right) =>
left.original.aliasesCount - right.original.aliasesCount,
meta: {
width: '15%',
className: 'whitespace-nowrap',
},
cell: ({ row }) => {
const { aliasesCount } = row.original;
return (
<span className="text-muted text-sm">
{aliasesCount} alias{aliasesCount !== 1 ? 'es' : ''}
</span>
);
},
}),
imageColumnHelper.accessor('sizeBytes', {
id: 'sizeBytes',
header: 'Size',
sortingFn: 'alphanumeric',
meta: {
width: '15%',
className: 'whitespace-nowrap',
},
cell: ({ getValue }) => (
<span className="text-sm">{humanize(getValue() ?? 0)}</span>
),
}),
];
export const cachedImages = columnHelper.accessor(
(node) => node.status?.images?.length ?? 0,
{
id: 'cachedImages',
header: 'Cached Images',
cell: (props) => (
<CachedImagesCell
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
),
}
);
function CachedImagesCell({
row: { original: node },
}: CellContext<NodeRowData, number>) {
const [isModalOpen, setIsModalOpen] = useState(false);
const nodeName = node.metadata?.name ?? 'node';
const images: NodeImage[] = node.status?.images ?? [];
return (
<>
<Button
color="link"
onClick={() => setIsModalOpen(true)}
icon={Layers}
className="!px-0"
data-cy={`node-cached-images-${nodeName}`}
>
{images.length}
</Button>
{isModalOpen && (
<CachedImagesModal
nodeName={nodeName}
images={images}
onDismiss={() => setIsModalOpen(false)}
/>
)}
</>
);
}
function CachedImagesModal({
nodeName,
images,
onDismiss,
}: {
nodeName: string;
images: NodeImage[];
onDismiss: () => void;
}) {
const tableState = useTableStateWithoutStorage('sizeBytes', true);
const rows = useMemo(
() =>
[...images]
.sort((left, right) => (right.sizeBytes ?? 0) - (left.sizeBytes ?? 0))
.map((image, index) => {
const names = image.names ?? [];
const primaryName = names[0] ?? '<untagged>';
const aliases = names.slice(1).join(' ');
const sizeBytes = image.sizeBytes ?? 0;
return {
id: `${primaryName}-${sizeBytes}-${index}`,
image: primaryName,
aliases,
aliasesCount: Math.max(names.length - 1, 0),
sizeBytes,
};
}),
[images]
);
const totalSizeBytes = useMemo(
() => rows.reduce((current, image) => current + image.sizeBytes, 0),
[rows]
);
return (
<Modal
onDismiss={onDismiss}
aria-label={`Cached images on ${nodeName}`}
dialogClassName="w-[min(1320px,calc(100vw-2rem))]"
className="pr-10 pt-7"
>
<ModalBody>
{images.length === 0 && (
<div className="text-muted">
No cached images reported on this node.
</div>
)}
{images.length > 0 && (
<Datatable<CachedImageRow>
disableSelect
dataset={rows}
columns={columns}
settingsManager={tableState}
title={`Cached Images on ${nodeName}`}
titleIcon={Layers}
description={`${pluralize(rows.length, 'image')}, ${humanize(
totalSizeBytes
)} total`}
getRowId={(row) => row.id}
data-cy="cached-images-datatable"
/>
)}
</ModalBody>
</Modal>
);
}