diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.css b/app/docker/components/datatables/images-datatable/imagesDatatable.css
deleted file mode 100644
index 28cc1d7c6..000000000
--- a/app/docker/components/datatables/images-datatable/imagesDatatable.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.show-dropdown {
- overflow: visible !important;
-}
diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html
deleted file mode 100644
index 819db41a9..000000000
--- a/app/docker/components/datatables/images-datatable/imagesDatatable.html
+++ /dev/null
@@ -1,260 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Filter
-
-
- Filter
-
-
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
- {{ item.Id | truncate : 40 }}
- Unused
- |
-
- {{ tag }}
- |
- {{ item.VirtualSize | humansize }} |
- {{ item.Created | getisodatefromtimestamp }} |
- {{ item.NodeName ? item.NodeName : '-' }} |
-
-
- Loading... |
-
-
- No image available. |
-
-
-
-
-
-
-
-
diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.js b/app/docker/components/datatables/images-datatable/imagesDatatable.js
deleted file mode 100644
index 381e80e52..000000000
--- a/app/docker/components/datatables/images-datatable/imagesDatatable.js
+++ /dev/null
@@ -1,18 +0,0 @@
-angular.module('portainer.docker').component('imagesDatatable', {
- templateUrl: './imagesDatatable.html',
- controller: 'ImagesDatatableController',
- bindings: {
- titleText: '@',
- titleIcon: '@',
- dataset: '<',
- tableKey: '@',
- orderBy: '@',
- reverseOrder: '<',
- showHostColumn: '<',
- removeAction: '<',
- downloadAction: '<',
- forceRemoveAction: '<',
- exportInProgress: '<',
- refreshCallback: '<',
- },
-});
diff --git a/app/docker/components/datatables/images-datatable/imagesDatatableController.js b/app/docker/components/datatables/images-datatable/imagesDatatableController.js
deleted file mode 100644
index a602bd72d..000000000
--- a/app/docker/components/datatables/images-datatable/imagesDatatableController.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import './imagesDatatable.css';
-angular.module('portainer.docker').controller('ImagesDatatableController', [
- '$scope',
- '$controller',
- 'DatatableService',
- function ($scope, $controller, DatatableService) {
- angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
-
- var ctrl = this;
-
- this.filters = {
- state: {
- open: false,
- enabled: false,
- showUsedImages: true,
- showUnusedImages: true,
- },
- };
-
- this.applyFilters = function (value) {
- var image = value;
- var filters = ctrl.filters;
- if ((image.ContainerCount === 0 && filters.state.showUnusedImages) || (image.ContainerCount !== 0 && filters.state.showUsedImages)) {
- return true;
- }
- return false;
- };
-
- this.onstateFilterChange = function () {
- var filters = this.filters.state;
- var filtered = false;
- if (!filters.showUsedImages || !filters.showUnusedImages) {
- filtered = true;
- }
- this.filters.state.enabled = filtered;
- DatatableService.setDataTableFilters(this.tableKey, this.filters);
- };
-
- this.$onInit = function () {
- this.setDefaults();
- this.prepareTableFromDataset();
-
- this.state.orderBy = this.orderBy;
- var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
- if (storedOrder !== null) {
- this.state.reverseOrder = storedOrder.reverse;
- this.state.orderBy = storedOrder.orderBy;
- }
-
- var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
- if (textFilter !== null) {
- this.state.textFilter = textFilter;
- this.onTextFilterChange();
- }
-
- var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
- if (storedFilters !== null) {
- this.filters = storedFilters;
- }
- if (this.filters && this.filters.state) {
- this.filters.state.open = false;
- }
-
- var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
- if (storedSettings !== null) {
- this.settings = storedSettings;
- this.settings.open = false;
- }
-
- this.onSettingsRepeaterChange();
- };
- },
-]);
diff --git a/app/docker/models/image.js b/app/docker/models/image.js
index 3e81fee8f..9b103a1e8 100644
--- a/app/docker/models/image.js
+++ b/app/docker/models/image.js
@@ -15,7 +15,7 @@ export function ImageViewModel(data) {
}
this.VirtualSize = data.VirtualSize;
- this.ContainerCount = data.ContainerCount;
+ this.Used = data.Used;
if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts
index 87bbfc607..0acc32a64 100644
--- a/app/docker/react/components/index.ts
+++ b/app/docker/react/components/index.ts
@@ -16,6 +16,7 @@ import { GpusList } from '@/react/docker/host/SetupView/GpusList';
import { GpusInsights } from '@/react/docker/host/SetupView/GpusInsights';
import { InsightsBox } from '@/react/components/InsightsBox';
import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert';
+import { ImagesDatatable } from '@/react/docker/images/ListView/ImagesDatatable/ImagesDatatable';
export const componentsModule = angular
.module('portainer.docker.react.components', [])
@@ -66,4 +67,17 @@ export const componentsModule = angular
])
)
.component('betaAlert', r2a(BetaAlert, ['className', 'message', 'isHtml']))
- .component('gpusInsights', r2a(GpusInsights, [])).name;
+ .component('gpusInsights', r2a(GpusInsights, []))
+ .component(
+ 'dockerImagesDatatable',
+ r2a(withUIRouter(withCurrentUser(ImagesDatatable)), [
+ 'dataset',
+ 'environment',
+ 'onRemove',
+ 'isExportInProgress',
+ 'isHostColumnVisible',
+ 'onDownload',
+ 'onRefresh',
+ 'onRemove',
+ ])
+ ).name;
diff --git a/app/docker/services/imageService.js b/app/docker/services/imageService.js
index 5f2c7290c..fc29d0602 100644
--- a/app/docker/services/imageService.js
+++ b/app/docker/services/imageService.js
@@ -1,3 +1,4 @@
+import _ from 'lodash';
import { getUniqueTagListFromImages } from '@/react/docker/images/utils';
import { ImageViewModel } from '../models/image';
import { ImageDetailsViewModel } from '../models/imageDetails';
@@ -41,15 +42,10 @@ angular.module('portainer.docker').factory('ImageService', [
})
.then(function success(data) {
var containers = data.containers;
+ const containerByImageId = _.groupBy(containers, 'ImageID');
var images = data.images.map(function (item) {
- item.ContainerCount = 0;
- for (var i = 0; i < containers.length; i++) {
- var container = containers[i];
- if (container.ImageID === item.Id) {
- item.ContainerCount++;
- }
- }
+ item.Used = !!containerByImageId[item.Id] && containerByImageId[item.Id].length > 0;
return new ImageViewModel(item);
});
diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html
index 6c5952681..5d6dc784e 100644
--- a/app/docker/views/images/images.html
+++ b/app/docker/views/images/images.html
@@ -44,20 +44,17 @@
-
+
+
diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js
index c09c053ae..bc5674b81 100644
--- a/app/docker/views/images/imagesController.js
+++ b/app/docker/views/images/imagesController.js
@@ -15,7 +15,7 @@ angular.module('portainer.docker').controller('ImagesController', [
'Blob',
'endpoint',
'$async',
- function ($scope, $state, Authentication, ImageService, Notifications, HttpRequestHelper, FileSaver, Blob, endpoint, $async) {
+ function ($scope, $state, Authentication, ImageService, Notifications, HttpRequestHelper, FileSaver, Blob, endpoint) {
$scope.endpoint = endpoint;
$scope.isAdmin = Authentication.isAdmin();
@@ -54,40 +54,32 @@ angular.module('portainer.docker').controller('ImagesController', [
});
};
- $scope.confirmForceRemove = confirmForceRemove;
- function confirmForceRemove(selectedItems, force) {
- return $async(async () => {
- const confirmed = await confirmDestructive({
- title: 'Are you sure?',
- message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.',
- confirmButton: buildConfirmButton('Remove the image', 'danger'),
- });
-
- if (!confirmed) {
- return;
- }
-
- $scope.removeAction(selectedItems, force);
+ function confirmImageForceRemoval() {
+ return confirmDestructive({
+ title: 'Are you sure?',
+ message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.',
+ confirmButton: buildConfirmButton('Remove the image', 'danger'),
});
}
- $scope.confirmRemove = confirmRemove;
- function confirmRemove(selectedItems) {
- return $async(async () => {
- const confirmed = await confirmDestructive({
- title: 'Are you sure?',
- message: 'Removing the image will remove all tags associated to that image. Are you sure you want to remove the image?',
- confirmButton: buildConfirmButton('Remove the image', 'danger'),
- });
-
- if (!confirmed) {
- return;
- }
-
- $scope.removeAction(selectedItems, false);
+ function confirmRegularRemove() {
+ return confirmDestructive({
+ title: 'Are you sure?',
+ message: 'Removing the image will remove all tags associated to that image. Are you sure you want to remove the image?',
+ confirmButton: buildConfirmButton('Remove the image', 'danger'),
});
}
+ $scope.confirmRemovalAction = async function (selectedItems, force) {
+ const confirmed = await (force ? confirmImageForceRemoval() : confirmRegularRemove());
+
+ if (!confirmed) {
+ return;
+ }
+
+ $scope.removeAction(selectedItems, force);
+ };
+
function isAuthorizedToDownload(selectedItems) {
for (var i = 0; i < selectedItems.length; i++) {
var image = selectedItems[i];
diff --git a/app/portainer/helpers/array.ts b/app/portainer/helpers/array.ts
new file mode 100644
index 000000000..f3eec3621
--- /dev/null
+++ b/app/portainer/helpers/array.ts
@@ -0,0 +1,15 @@
+export 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];
+}
diff --git a/app/react/components/buttons/Button.tsx b/app/react/components/buttons/Button.tsx
index acd9d4f61..66bfc08fb 100644
--- a/app/react/components/buttons/Button.tsx
+++ b/app/react/components/buttons/Button.tsx
@@ -1,6 +1,7 @@
import {
AriaAttributes,
ComponentType,
+ forwardRef,
MouseEventHandler,
PropsWithChildren,
ReactNode,
@@ -39,9 +40,17 @@ export interface Props
type?: Type;
as?: ComponentType | string;
onClick?: MouseEventHandler;
+ mRef?: React.ForwardedRef;
props?: TasProps;
}
+export const ButtonWithRef = forwardRef>(
+ (props, ref) => (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+ )
+);
+
export function Button({
type = 'button',
color = 'primary',
@@ -54,15 +63,19 @@ export function Button({
children,
as = 'button',
props,
+ mRef,
...ariaProps
}: PropsWithChildren>) {
const Component = as as 'button';
return (
{
if (!disabled) {
onClick?.(e);
diff --git a/app/react/components/datatables/Filter.tsx b/app/react/components/datatables/Filter.tsx
index f2d4f1280..c47eabf30 100644
--- a/app/react/components/datatables/Filter.tsx
+++ b/app/react/components/datatables/Filter.tsx
@@ -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>(
);
};
}
-
-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];
-}
diff --git a/app/react/components/datatables/types.ts b/app/react/components/datatables/types.ts
index f2e107b7a..321802b7a 100644
--- a/app/react/components/datatables/types.ts
+++ b/app/react/components/datatables/types.ts
@@ -93,8 +93,23 @@ export function createPersistedStore(
...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 {
+ // eslint-disable-next-line class-methods-use-this
+ wrapped() {
+ return createPersistedStore('', '');
+ }
+}
+
+export type CreatePersistedStoreReturn<
+ T extends BasicTableSettings = BasicTableSettings
+> = ReturnType['wrapped']>;
diff --git a/app/react/components/datatables/useTableState.ts b/app/react/components/datatables/useTableState.ts
index 4a530f0fc..ce3d8cdf6 100644
--- a/app/react/components/datatables/useTableState.ts
+++ b/app/react/components/datatables/useTableState.ts
@@ -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 {
- // eslint-disable-next-line class-methods-use-this
- wrapped() {
- return createPersistedStore('', '');
- }
-}
+import { BasicTableSettings, CreatePersistedStoreReturn } from './types';
export function useTableState<
TSettings extends BasicTableSettings = BasicTableSettings
->(store: ReturnType['wrapped']>, storageKey: string) {
+>(store: CreatePersistedStoreReturn, storageKey: string) {
const settings = useStore(store);
const [search, setSearch] = useSearchBarState(storageKey);
diff --git a/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx b/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx
new file mode 100644
index 000000000..333bd878f
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx
@@ -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(
+ tableKey,
+ 'tags',
+ (set) => ({
+ ...refreshableSettings(set),
+ })
+);
+
+export function ImagesDatatable({
+ dataset,
+
+ environment,
+ isHostColumnVisible,
+ isExportInProgress,
+ onDownload,
+ onRefresh,
+ onRemove,
+}: {
+ dataset: Array;
+ environment: Environment;
+ isHostColumnVisible: boolean;
+
+ onDownload: (images: Array) => void;
+ onRemove: (images: Array, force: true) => void;
+ onRefresh: () => Promise;
+ isExportInProgress: boolean;
+}) {
+ const tableState = useTableState(settingsStore, tableKey);
+ const columns = useMemo(
+ () => (isHostColumnVisible ? [...defColumns, hostColumn] : defColumns),
+ [isHostColumnVisible]
+ );
+
+ useRepeater(tableState.autoRefreshRate, onRefresh);
+
+ return (
+
+ (
+
+
+
+
+
+
+
+
+
+ )}
+ dataset={dataset}
+ settingsManager={tableState}
+ columns={columns}
+ emptyContentLabel="No images found"
+ renderTableSettings={() => (
+
+ tableState.setAutoRefreshRate(value)}
+ />
+
+ )}
+ />
+
+ );
+}
+
+function RemoveButtonMenu({
+ onRemove,
+ selectedItems,
+}: {
+ selectedItems: Array;
+ onRemove(selectedItems: Array, force: boolean): void;
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function ImportExportButtons({
+ isExportInProgress,
+ selectedItems,
+ onExportClick,
+}: {
+ isExportInProgress: boolean;
+ selectedItems: Array;
+ onExportClick(selectedItems: Array): void;
+}) {
+ return (
+
+
+
+
+
+ onExportClick(selectedItems)}
+ disabled={selectedItems.length === 0}
+ >
+ Export
+
+
+
+ );
+}
diff --git a/app/react/docker/images/ListView/ImagesDatatable/RowContext.ts b/app/react/docker/images/ListView/ImagesDatatable/RowContext.ts
new file mode 100644
index 000000000..66af35873
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/RowContext.ts
@@ -0,0 +1,11 @@
+import { Environment } from '@/react/portainer/environments/types';
+
+import { createRowContext } from '@@/datatables/RowContext';
+
+interface RowContextState {
+ environment: Environment;
+}
+
+const { RowProvider, useRowContext } = createRowContext();
+
+export { RowProvider, useRowContext };
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/created.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/created.tsx
new file mode 100644
index 000000000..338fddba7
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/created.tsx
@@ -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);
+ },
+});
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/helper.ts b/app/react/docker/images/ListView/ImagesDatatable/columns/helper.ts
new file mode 100644
index 000000000..4bf279376
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/helper.ts
@@ -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
+ *
+ *
+ */
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/host.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/host.tsx
new file mode 100644
index 000000000..8728f53c4
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/host.tsx
@@ -0,0 +1,9 @@
+import { columnHelper } from './helper';
+
+export const host = columnHelper.accessor('NodeName', {
+ header: 'Host',
+ cell: ({ getValue }) => {
+ const value = getValue();
+ return value || '-';
+ },
+});
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx
new file mode 100644
index 000000000..f6f2940bd
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx
@@ -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({
+ column: { getFilterValue, setFilterValue, id },
+}: {
+ column: Column;
+}) {
+ const options = ['Used', 'Unused'];
+
+ const value = getFilterValue();
+
+ const valueAsArray = getValueAsArrayOfStrings(value);
+
+ return (
+
+ );
+}
+
+function Cell({
+ getValue,
+ row: { original: image },
+}: CellContext) {
+ const name = getValue();
+
+ const linkProps = useSref('.image', {
+ id: image.Id,
+ imageId: image.Id,
+ });
+
+ return (
+ <>
+
+ {truncate(name, 40)}
+
+ {!image.Used && (
+ Unused
+ )}
+ >
+ );
+}
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/index.ts b/app/react/docker/images/ListView/ImagesDatatable/columns/index.ts
new file mode 100644
index 000000000..6b24c29ee
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/index.ts
@@ -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];
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/size.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/size.tsx
new file mode 100644
index 000000000..67c8a8b8c
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/size.tsx
@@ -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);
+ },
+});
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx
new file mode 100644
index 000000000..9e0a3da4d
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx
@@ -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) {
+ const repoTags = getValue();
+
+ return (
+ <>
+ {repoTags.map((tag, idx) => (
+
+ {tag}
+
+ ))}
+ >
+ );
+}
diff --git a/app/react/docker/images/types/response.ts b/app/react/docker/images/types/response.ts
index 7c8b19f54..41f384a82 100644
--- a/app/react/docker/images/types/response.ts
+++ b/app/react/docker/images/types/response.ts
@@ -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;
};
diff --git a/package.json b/package.json
index 06a9967a6..f766309d5 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"@reach/dialog": "^0.17.0",
"@reach/menu-button": "^0.16.1",
"@tanstack/react-table": "^8.8.5",
+ "@reach/popover": "^0.18.0",
"@tippyjs/react": "^4.2.6",
"@uirouter/angularjs": "1.0.11",
"@uirouter/react": "^1.0.7",
diff --git a/yarn.lock b/yarn.lock
index 147c9dcad..a02da8ea0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2802,7 +2802,7 @@
tabbable "^4.0.0"
tslib "^2.3.0"
-"@reach/popover@0.18.0":
+"@reach/popover@0.18.0", "@reach/popover@^0.18.0":
version "0.18.0"
resolved "https://registry.yarnpkg.com/@reach/popover/-/popover-0.18.0.tgz#1eba3e9ed826ac69dfdf3b01a1dab15ca889b5fc"
integrity sha512-mpnWWn4w74L2U7fcneVdA6Fz3yKWNdZIRMoK8s6H7F8U2dLM/qN7AjzjEBqi6LXKb3Uf1ge4KHSbMixW0BygJQ==