import React from 'react'; import { Button } from "@patternfly/react-core/dist/esm/components/Button"; import { Card, CardBody, CardFooter, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card"; import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js'; import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex"; import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection"; import { Text, TextVariants } from "@patternfly/react-core/dist/esm/components/Text"; import { cellWidth } from '@patternfly/react-table'; import cockpit from 'cockpit'; import { ListingTable } from "cockpit-components-table.jsx"; import { ListingPanel } from 'cockpit-components-listing-panel.jsx'; import ImageDetails from './ImageDetails.jsx'; import ImageHistory from './ImageHistory.jsx'; import { ImageRunModal } from './ImageRunModal.jsx'; import { ImageSearchModal } from './ImageSearchModal.jsx'; import { ImageDeleteModal } from './ImageDeleteModal.jsx'; import PruneUnusedImagesModal from './PruneUnusedImagesModal.jsx'; import * as client from './client.js'; import * as utils from './util.js'; import { useDialogs, DialogsContext } from "dialogs.jsx"; import './Images.css'; import '@patternfly/react-styles/css/utilities/Sizing/sizing.css'; import { KebabDropdown } from "cockpit-components-dropdown.jsx"; const _ = cockpit.gettext; class Images extends React.Component { static contextType = DialogsContext; constructor(props) { super(props); this.state = { intermediateOpened: false, isExpanded: false, }; this.downloadImage = this.downloadImage.bind(this); this.renderRow = this.renderRow.bind(this); } downloadImage(imageName, imageTag, system) { let pullImageId = imageName; if (imageTag) pullImageId += ":" + imageTag; this.setState({ imageDownloadInProgress: imageName }); client.pullImage(system, pullImageId) .then(() => { this.setState({ imageDownloadInProgress: undefined }); }) .catch(ex => { const error = cockpit.format(_("Failed to download image $0:$1"), imageName, imageTag || "latest"); const errorDetail = ( <>

{_("Error message")}: {cockpit.format("$0 $1", ex.message, ex.reason)}

); this.setState({ imageDownloadInProgress: undefined }); this.props.onAddNotification({ type: 'danger', error, errorDetail }); }); } onOpenNewImagesDialog = () => { const Dialogs = this.context; Dialogs.show( ); }; onOpenPruneUnusedImagesDialog = () => { this.setState({ showPruneUnusedImagesModal: true }); }; getUsedByText(image) { const { imageContainerList } = this.props; if (imageContainerList === null) { return { title: _("unused"), count: 0 }; } const containers = imageContainerList[image.Id + image.isSystem.toString()]; if (containers !== undefined) { const title = cockpit.format(cockpit.ngettext("$0 container", "$0 containers", containers.length), containers.length); return { title, count: containers.length }; } else { return { title: _("unused"), count: 0 }; } } calculateStats = () => { const { images, imageContainerList } = this.props; const unusedImages = []; const imageStats = { imagesTotal: 0, imagesSize: 0, unusedTotal: 0, unusedSize: 0, }; if (imageContainerList === null) { return { imageStats, unusedImages }; } if (images !== null) { Object.keys(images).forEach(id => { const image = images[id]; imageStats.imagesTotal += 1; imageStats.imagesSize += image.Size; const usedBy = imageContainerList[image.Id + image.isSystem.toString()]; if (usedBy === undefined) { imageStats.unusedTotal += 1; imageStats.unusedSize += image.Size; unusedImages.push(image); } }); } return { imageStats, unusedImages }; }; renderRow(image) { const tabs = []; const { title: usedByText, count: usedByCount } = this.getUsedByText(image); const columns = [ { title: utils.image_name(image), header: true, props: { modifier: "breakWord" } }, { title: image.isSystem ? _("system") :
{_("user:")} {this.props.user}
, props: { className: "ignore-pixels", modifier: "nowrap" } }, { title: utils.localize_time(image.Created), props: { className: "ignore-pixels" } }, { title: utils.truncate_id(image.Id), props: { className: "ignore-pixels" } }, { title: cockpit.format_bytes(image.Size, 1000), props: { className: "ignore-pixels", modifier: "nowrap" } }, { title: {usedByText}, props: { className: "ignore-pixels", modifier: "nowrap" } }, { title: , props: { className: 'pf-v5-c-table__action content-action' } }, ]; tabs.push({ name: _("Details"), renderer: ImageDetails, data: { image, containers: this.props.imageContainerList !== null ? this.props.imageContainerList[image.Id + image.isSystem.toString()] : null, showAll: this.props.showAll, } }); tabs.push({ name: _("History"), renderer: ImageHistory, data: { image, } }); return { expandedContent: , columns, props: { key: image.Id + image.isSystem.toString(), "data-row-id": image.Id + image.isSystem.toString(), }, }; } render() { const columnTitles = [ { title: _("Image"), transforms: [cellWidth(20)] }, { title: _("Owner"), props: { className: "ignore-pixels" } }, { title: _("Created"), props: { className: "ignore-pixels", width: 15 } }, { title: _("ID"), props: { className: "ignore-pixels" } }, { title: _("Disk space"), props: { className: "ignore-pixels" } }, { title: _("Used by"), props: { className: "ignore-pixels" } }, ]; let emptyCaption = _("No images"); if (this.props.images === null) emptyCaption = "Loading..."; else if (this.props.textFilter.length > 0) emptyCaption = _("No images that match the current filter"); const intermediateOpened = this.state.intermediateOpened; let filtered = []; if (this.props.images !== null) { filtered = Object.keys(this.props.images).filter(id => { if (this.props.userServiceAvailable && this.props.systemServiceAvailable && this.props.ownerFilter !== "all") { if (this.props.ownerFilter === "system" && !this.props.images[id].isSystem) return false; if (this.props.ownerFilter !== "system" && this.props.images[id].isSystem) return false; } const tags = this.props.images[id].RepoTags || []; if (!intermediateOpened && tags.length < 1) return false; if (this.props.textFilter.length > 0) return tags.some(tag => tag.toLowerCase().indexOf(this.props.textFilter.toLowerCase()) >= 0); return true; }); } filtered.sort((a, b) => { // User images are in front of system ones if (this.props.images[a].isSystem !== this.props.images[b].isSystem) return this.props.images[a].isSystem ? 1 : -1; const name_a = this.props.images[a].RepoTags ? this.props.images[a].RepoTags[0] : ""; const name_b = this.props.images[b].RepoTags ? this.props.images[b].RepoTags[0] : ""; if (name_a === "") return 1; if (name_b === "") return -1; return name_a > name_b ? 1 : -1; }); const imageRows = filtered.map(id => this.renderRow(this.props.images[id])); const interim = this.props.images && Object.keys(this.props.images).some(id => { // Intermediate image does not have any tags if (this.props.images[id].RepoTags && this.props.images[id].RepoTags.length > 0) return false; // Only filter by selected user if (this.props.userServiceAvailable && this.props.systemServiceAvailable && this.props.ownerFilter !== "all") { if (this.props.ownerFilter === "system" && !this.props.images[id].isSystem) return false; if (this.props.ownerFilter !== "system" && this.props.images[id].isSystem) return false; } // Any text filter hides all images if (this.props.textFilter.length > 0) return false; return true; }); let toggleIntermediate = ""; if (interim) { toggleIntermediate = ( ); } const cardBody = ( <> {toggleIntermediate} ); const { imageStats, unusedImages } = this.calculateStats(); const imageTitleStats = ( <> {cockpit.format(cockpit.ngettext("$0 image total, $1", "$0 images total, $1", imageStats.imagesTotal), imageStats.imagesTotal, cockpit.format_bytes(imageStats.imagesSize, 1000))} {imageStats.unusedTotal !== 0 && {cockpit.format(cockpit.ngettext("$0 unused image, $1", "$0 unused images, $1", imageStats.unusedTotal), imageStats.unusedTotal, cockpit.format_bytes(imageStats.unusedSize, 1000))} } ); return ( {_("Images")} {imageTitleStats} {this.props.images && Object.keys(this.props.images).length ? this.setState(prevState => ({ isExpanded: !prevState.isExpanded }))} isExpanded={this.state.isExpanded}> {cardBody} : cardBody} {/* The PruneUnusedImagesModal dialog needs to keep * its list of unused images in sync with reality at * all times since the API call will delete whatever * is unused at the exact time of call, and the * dialog better be showing the correct list of * unused images at that time. Thus, we can't use * Dialog.show for it but include it here in the * DOM. */} {this.state.showPruneUnusedImagesModal && this.setState({ showPruneUnusedImagesModal: false })} unusedImages={unusedImages} onAddNotification={this.props.onAddNotification} userServiceAvailable={this.props.userServiceAvailable} systemServiceAvailable={this.props.systemServiceAvailable} /> } {this.state.imageDownloadInProgress &&
{_("Pulling")} {this.state.imageDownloadInProgress}...
}
); } } const ImageOverActions = ({ handleDownloadNewImage, handlePruneUsedImages, unusedImages }) => { const actions = [ handleDownloadNewImage()} > {_("Download new image")} , handlePruneUsedImages()} isDisabled={unusedImages.length === 0} isAriaDisabled={unusedImages.length === 0} > {_("Prune unused images")} ]; return ( ); }; const ImageActions = ({ image, onAddNotification, user, systemServiceAvailable, userServiceAvailable }) => { const Dialogs = useDialogs(); const runImage = () => { Dialogs.show( {(podmanInfo) => ( {(Dialogs) => ( )} )} ); }; const removeImage = () => { Dialogs.show(); }; const runImageAction = ( ); const dropdownActions = [ {_("Create container")} , {_("Delete")} ]; return ( <> {runImageAction} ); }; export default Images;