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 @@ -
- - -
-
-
- -
- {{ $ctrl.titleText }} -
- -
-
- - - -
- -
- - -
- - -
-
- - - - - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - -
- -
- 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 + + + + )} + 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 ( + + + + + + Toggle Dropdown + + +
+ { + onRemove(selectedItems, true); + }} + > + Force Remove + +
+
+
+
+
+ ); +} + +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==