LCOV - code coverage report
Current view: top level - src - Images.jsx Coverage Total Hit
Test: cockpit-podman Lines: 97.2 % 353 343
Test Date: 2025-05-21 17:35:03

            Line data    Source code
       1           43 : import React from 'react';
       2              : 
       3              : import { Button } from "@patternfly/react-core/dist/esm/components/Button";
       4              : import { Card, CardBody, CardFooter, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card";
       5              : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content";
       6              : import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
       7              : import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection";
       8              : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
       9              : import { cellWidth } from '@patternfly/react-table';
      10              : import { KebabDropdown } from "cockpit-components-dropdown.jsx";
      11              : import { useDialogs, DialogsContext } from "dialogs.jsx";
      12              : 
      13              : import cockpit from 'cockpit';
      14              : import { ListingPanel } from 'cockpit-components-listing-panel';
      15              : import { ListingTable } from "cockpit-components-table";
      16              : 
      17              : import { ImageDeleteModal } from './ImageDeleteModal.jsx';
      18              : import ImageDetails from './ImageDetails.jsx';
      19              : import ImageHistory from './ImageHistory.jsx';
      20              : import { ImageRunModal } from './ImageRunModal.jsx';
      21              : import { ImageSearchModal } from './ImageSearchModal.jsx';
      22              : import PruneUnusedImagesModal from './PruneUnusedImagesModal.jsx';
      23              : import * as client from './client.js';
      24              : import * as utils from './util.js';
      25              : 
      26              : import './Images.css';
      27              : import '@patternfly/react-styles/css/utilities/Sizing/sizing.css';
      28              : 
      29           43 : const _ = cockpit.gettext;
      30              : 
      31           43 : class Images extends React.Component {
      32           43 :     static contextType = DialogsContext;
      33              : 
      34           43 :     constructor(props) {
      35           43 :         super(props);
      36           43 :         this.state = {
      37           43 :             intermediateOpened: false,
      38           43 :             isExpanded: false,
      39              :             // List of container image names which are being downloaded
      40           43 :             imageDownloadInProgress: [],
      41           43 :             showPruneUnusedImagesModal: false,
      42           43 :         };
      43              : 
      44           43 :         this.downloadImage = this.downloadImage.bind(this);
      45           43 :         this.renderRow = this.renderRow.bind(this);
      46           43 :     }
      47              : 
      48            2 :     downloadImage(imageName, imageTag, con) {
      49            2 :         let pullImageId = imageName;
      50            2 :         if (imageTag)
      51            1 :             pullImageId += ":" + imageTag;
      52              : 
      53            2 :         this.setState(previous => ({ imageDownloadInProgress: [...previous.imageDownloadInProgress, imageName] }));
      54            2 :         client.pullImage(con, pullImageId)
      55            2 :                 .then(() => {
      56            2 :                     this.setState(previous => ({ imageDownloadInProgress: previous.imageDownloadInProgress.filter((image) => image != imageName) }));
      57            2 :                 })
      58            1 :                 .catch(ex => {
      59            0 :                     const error = cockpit.format(_("Failed to download image $0:$1"), imageName, imageTag || "latest");
      60            1 :                     const errorDetail = (
      61            1 :                         <p> {_("Error message")}:
      62            1 :                             <samp>{cockpit.format("$0 $1", ex.message, ex.reason)}</samp>
      63            1 :                         </p>
      64              :                     );
      65            1 :                     this.setState(previous => ({ imageDownloadInProgress: previous.imageDownloadInProgress.filter((image) => image != imageName) }));
      66            1 :                     this.props.onAddNotification({ type: 'danger', error, errorDetail });
      67            1 :                 });
      68            2 :     }
      69              : 
      70            1 :     onOpenNewImagesDialog = () => {
      71            1 :         const Dialogs = this.context;
      72            1 :         Dialogs.show(<ImageSearchModal downloadImage={this.downloadImage} users={this.props.users} />);
      73            1 :     };
      74              : 
      75            1 :     _con_for = image => this.props.users.find(u => u.uid === image.uid).con;
      76              : 
      77            1 :     onPullAllImages = () => Object.values(this.props.images).forEach(image => {
      78              :         // ignore nameless (intermediate) images and the localhost/ pseudo-registry (which cannot be pulled)
      79            1 :         if (image.RepoTags?.find(tag => !tag.startsWith("localhost/")))
      80            1 :             this.downloadImage(image.RepoTags[0], null, this._con_for(image));
      81              :         else
      82            1 :             utils.debug("onPullAllImages: ignoring image", image);
      83            1 :     });
      84              : 
      85            4 :     onOpenPruneUnusedImagesDialog = () => {
      86            4 :         this.setState({ showPruneUnusedImagesModal: true });
      87            4 :     };
      88              : 
      89           43 :     getUsedByText(image) {
      90           43 :         const { imageContainerList } = this.props;
      91            2 :         if (imageContainerList === null) {
      92            2 :             return { title: _("unused"), count: 0 };
      93            2 :         }
      94           43 :         const containers = imageContainerList[image.key];
      95           41 :         if (containers !== undefined) {
      96           41 :             const title = cockpit.format(cockpit.ngettext("$0 container", "$0 containers", containers.length), containers.length);
      97           41 :             return { title, count: containers.length };
      98           40 :         } else {
      99           42 :             return { title: _("unused"), count: 0 };
     100           42 :         }
     101           43 :     }
     102              : 
     103           43 :     calculateStats = () => {
     104           43 :         const { images, imageContainerList } = this.props;
     105           43 :         const unusedImages = [];
     106           43 :         const imageStats = {
     107           43 :             imagesTotal: 0,
     108           43 :             imagesSize: 0,
     109           43 :             unusedTotal: 0,
     110           43 :             unusedSize: 0,
     111           43 :         };
     112              : 
     113           38 :         if (imageContainerList === null) {
     114           38 :             return { imageStats, unusedImages };
     115           38 :         }
     116              : 
     117           43 :         if (images !== null) {
     118           43 :             Object.keys(images).forEach(id => {
     119           43 :                 const image = images[id];
     120           43 :                 imageStats.imagesTotal += 1;
     121           43 :                 imageStats.imagesSize += image.Size;
     122              : 
     123           43 :                 const usedBy = imageContainerList[image.key];
     124           42 :                 if (usedBy === undefined) {
     125           42 :                     imageStats.unusedTotal += 1;
     126           42 :                     imageStats.unusedSize += image.Size;
     127           42 :                     unusedImages.push(image);
     128           42 :                 }
     129           43 :             });
     130           43 :         }
     131              : 
     132           43 :         return { imageStats, unusedImages };
     133           43 :     };
     134              : 
     135           43 :     renderRow(image) {
     136           43 :         const tabs = [];
     137           43 :         const { title: usedByText, count: usedByCount } = this.getUsedByText(image);
     138              : 
     139           43 :         const user = this.props.users.find(user => user.uid === image.uid);
     140           43 :         cockpit.assert(user, `User not found for image uid ${image.uid}`);
     141              : 
     142           43 :         const columns = [
     143           43 :             { title: utils.image_name(image), header: true, props: { modifier: "breakWord" } },
     144           25 :             { title: (image.uid == 0) ? _("system") : <div><span className="ct-grey-text">{_("user:")} </span>{user.name}</div>, props: { className: "ignore-pixels", modifier: "nowrap" } },
     145           43 :             { title: <utils.RelativeTime time={image.Created * 1000} />, props: { className: "ignore-pixels" } },
     146           43 :             { title: utils.truncate_id(image.Id), props: { className: "ignore-pixels" } },
     147           43 :             { title: cockpit.format_bytes(image.Size), props: { className: "ignore-pixels", modifier: "nowrap" } },
     148           41 :             { title: <span className={usedByCount === 0 ? "ct-grey-text" : ""}>{usedByText}</span>, props: { className: "ignore-pixels", modifier: "nowrap" } },
     149           43 :             {
     150           43 :                 title: <ImageActions con={user.con}
     151           43 :                                      image={image}
     152           43 :                                      onAddNotification={this.props.onAddNotification}
     153           43 :                                      users={this.props.users}
     154           43 :                                      downloadImage={this.downloadImage} />,
     155           43 :                 props: { className: 'pf-v6-c-table__action content-action' }
     156           43 :             },
     157           43 :         ];
     158              : 
     159           43 :         tabs.push({
     160           43 :             name: _("Details"),
     161           43 :             renderer: ImageDetails,
     162           43 :             data: {
     163           43 :                 image,
     164            2 :                 containers: this.props.imageContainerList !== null ? this.props.imageContainerList[image.key] : null,
     165           43 :                 showAll: this.props.showAll,
     166           43 :             }
     167           43 :         });
     168           43 :         tabs.push({
     169           43 :             name: _("History"),
     170           43 :             renderer: ImageHistory,
     171           43 :             data: { con: user.con, image }
     172           43 :         });
     173           43 :         return {
     174           43 :             expandedContent: <ListingPanel
     175           43 :                                 colSpan='8'
     176           43 :                                 tabRenderers={tabs} />,
     177           43 :             columns,
     178           43 :             props: {
     179           43 :                 key: image.key,
     180           43 :                 "data-row-id": image.key,
     181           43 :             },
     182           43 :         };
     183           43 :     }
     184              : 
     185           43 :     render() {
     186           43 :         const columnTitles = [
     187           43 :             { title: _("Image"), transforms: [cellWidth(20)] },
     188           43 :             { title: _("Owner"), props: { className: "ignore-pixels" } },
     189           43 :             { title: _("Created"), props: { className: "ignore-pixels", width: 15 } },
     190           43 :             { title: _("ID"), props: { className: "ignore-pixels" } },
     191           43 :             { title: _("Disk space"), props: { className: "ignore-pixels" } },
     192           43 :             { title: _("Used by"), props: { className: "ignore-pixels" } },
     193           43 :         ];
     194           43 :         let emptyCaption = _("No images");
     195           43 :         if (this.props.images === null)
     196           43 :             emptyCaption = "Loading...";
     197           43 :         else if (this.props.textFilter.length > 0)
     198            3 :             emptyCaption = _("No images that match the current filter");
     199              : 
     200           43 :         const intermediateOpened = this.state.intermediateOpened;
     201              : 
     202           43 :         let filtered = [];
     203           43 :         if (this.props.images !== null) {
     204           43 :             filtered = Object.keys(this.props.images).filter(id => {
     205            1 :                 if (this.props.ownerFilter !== "all") {
     206            1 :                     if (this.props.ownerFilter === "user")
     207            0 :                         return this.props.images[id].uid === null;
     208            1 :                     return this.props.images[id].uid === this.props.ownerFilter;
     209            1 :                 }
     210              : 
     211            4 :                 const tags = this.props.images[id].RepoTags || [];
     212           42 :                 if (!intermediateOpened && tags.length < 1)
     213            4 :                     return false;
     214           42 :                 if (this.props.textFilter.length > 0)
     215            3 :                     return tags.some(tag => tag.toLowerCase().indexOf(this.props.textFilter.toLowerCase()) >= 0);
     216           42 :                 return true;
     217           43 :             });
     218           43 :         }
     219              : 
     220           41 :         filtered.sort((a, b) => {
     221              :             // User images are in front of system ones
     222           41 :             if (this.props.images[a].uid !== this.props.images[b].uid)
     223           13 :                 return this.props.images[a].uid === 0 ? 1 : -1;
     224            1 :             const name_a = this.props.images[a].RepoTags ? this.props.images[a].RepoTags[0] : "";
     225            0 :             const name_b = this.props.images[b].RepoTags ? this.props.images[b].RepoTags[0] : "";
     226           41 :             if (name_a === "")
     227            1 :                 return 1;
     228           41 :             if (name_b === "")
     229            0 :                 return -1;
     230           32 :             return name_a > name_b ? 1 : -1;
     231           41 :         });
     232              : 
     233           43 :         const imageRows = filtered.map(id => this.renderRow(this.props.images[id]));
     234              : 
     235           43 :         const interim = this.props.images && Object.keys(this.props.images).some(id => {
     236              :             // Intermediate image does not have any tags
     237           43 :             if (this.props.images[id].RepoTags && this.props.images[id].RepoTags.length > 0)
     238           43 :                 return false;
     239              : 
     240              :             // Only filter by selected user
     241            0 :             if (this.props.ownerFilter !== "all") {
     242            0 :                 if (this.props.ownerFilter === "user")
     243            0 :                     return this.props.images[id].uid === null;
     244            0 :                 return this.props.images[id].uid === this.props.ownerFilter;
     245            0 :             }
     246              : 
     247              :             // Any text filter hides all images
     248            4 :             if (this.props.textFilter.length > 0)
     249            0 :                 return false;
     250              : 
     251            4 :             return true;
     252           43 :         });
     253              : 
     254           43 :         let toggleIntermediate = "";
     255            4 :         if (interim) {
     256            4 :             toggleIntermediate = (
     257            4 :                 <span className="listing-action">
     258            1 :                     <Button variant="link" onClick={() => this.setState({ intermediateOpened: !intermediateOpened, isExpanded: true })}>
     259            1 :                         {intermediateOpened ? _("Hide intermediate images") : _("Show intermediate images")}</Button>
     260            4 :                 </span>
     261              :             );
     262            4 :         }
     263           43 :         const cardBody = (
     264           43 :             <>
     265           43 :                 <ListingTable aria-label={_("Images")}
     266           43 :                               variant='compact'
     267           43 :                               emptyCaption={emptyCaption}
     268           43 :                               columns={columnTitles}
     269           43 :                               rows={imageRows} />
     270           43 :                 {toggleIntermediate}
     271           43 :             </>
     272              :         );
     273              : 
     274           43 :         const { imageStats, unusedImages } = this.calculateStats();
     275           43 :         const imageTitleStats = (
     276           43 :             <>
     277           43 :                 <Content component={ContentVariants.div}>
     278           43 :                     {cockpit.format(cockpit.ngettext("$0 image total, $1", "$0 images total, $1", imageStats.imagesTotal), imageStats.imagesTotal, cockpit.format_bytes(imageStats.imagesSize))}
     279           43 :                 </Content>
     280           43 :                 {imageStats.unusedTotal !== 0 &&
     281           42 :                 <Content component={ContentVariants.div}>
     282           42 :                     {cockpit.format(cockpit.ngettext("$0 unused image, $1", "$0 unused images, $1", imageStats.unusedTotal), imageStats.unusedTotal, cockpit.format_bytes(imageStats.unusedSize))}
     283           42 :                 </Content>
     284              :                 }
     285           43 :             </>
     286              :         );
     287              : 
     288           43 :         return (
     289           43 :             <Card id="containers-images" key="images" className="containers-images">
     290           43 :                 <CardHeader>
     291           43 :                     <Flex flexWrap={{ default: 'nowrap' }} className="pf-v6-u-w-100">
     292           43 :                         <FlexItem grow={{ default: 'grow' }}>
     293           43 :                             <Flex>
     294           43 :                                 <CardTitle>
     295           43 :                                     <Content component={ContentVariants.h1} className="containers-images-title">{_("Images")}</Content>
     296           43 :                                 </CardTitle>
     297           43 :                                 <Flex className="ignore-pixels" style={{ rowGap: "var(--pf-t--global--spacer--xs)" }}>{imageTitleStats}</Flex>
     298           43 :                             </Flex>
     299           43 :                         </FlexItem>
     300           43 :                         <FlexItem>
     301           43 :                             <ImageOverActions handleDownloadNewImage={this.onOpenNewImagesDialog}
     302           43 :                                               handlePullAllImages={this.onPullAllImages}
     303           43 :                                               handlePruneUsedImages={this.onOpenPruneUnusedImagesDialog}
     304           43 :                                               unusedImages={unusedImages} />
     305           43 :                         </FlexItem>
     306           43 :                     </Flex>
     307           43 :                 </CardHeader>
     308           43 :                 <CardBody>
     309           43 :                     {this.props.images && Object.keys(this.props.images).length
     310           15 :                         ? <ExpandableSection toggleText={this.state.isExpanded ? _("Hide images") : _("Show images")}
     311           15 :                                              onToggle={() => this.setState(prevState => ({ isExpanded: !prevState.isExpanded }))}
     312           43 :                                              isExpanded={this.state.isExpanded}>
     313           43 :                             {cardBody}
     314           43 :                         </ExpandableSection>
     315           43 :                         : cardBody}
     316           43 :                 </CardBody>
     317              :                 {/* The PruneUnusedImagesModal dialog needs to keep
     318              :                   * its list of unused images in sync with reality at
     319              :                   * all times since the API call will delete whatever
     320              :                   * is unused at the exact time of call, and the
     321              :                   * dialog better be showing the correct list of
     322              :                   * unused images at that time.  Thus, we can't use
     323              :                   * Dialog.show for it but include it here in the
     324              :                   * DOM. */}
     325           43 :                 { this.state.showPruneUnusedImagesModal &&
     326            4 :                     <PruneUnusedImagesModal
     327            4 :                     close={() => this.setState({ showPruneUnusedImagesModal: false })}
     328            4 :                     unusedImages={unusedImages}
     329            4 :                     onAddNotification={this.props.onAddNotification}
     330            4 :                     users={this.props.users} />
     331              :                 }
     332            2 :                 {this.state.imageDownloadInProgress.length > 0 && <CardFooter>
     333            2 :                     <div className='download-in-progress'> {_("Pulling")} {this.state.imageDownloadInProgress.join(', ')}... </div>
     334            2 :                 </CardFooter>}
     335           43 :             </Card>
     336              :         );
     337           43 :     }
     338           43 : }
     339              : 
     340           43 : const ImageOverActions = ({ handleDownloadNewImage, handlePullAllImages, handlePruneUsedImages, unusedImages }) => {
     341           43 :     const actions = [
     342           43 :         <DropdownItem
     343           43 :             key="download-new-image"
     344           43 :             component="button"
     345            1 :             onClick={() => handleDownloadNewImage()}
     346              :         >
     347           43 :             {_("Download new image")}
     348           43 :         </DropdownItem>,
     349           43 :         <DropdownItem
     350           43 :             key="pull-all-images"
     351           43 :             component="button"
     352            1 :             onClick={() => handlePullAllImages()}
     353              :         >
     354           43 :             {_("Pull all images")}
     355           43 :         </DropdownItem>,
     356           43 :         <DropdownItem
     357           43 :             key="prune-unused-images"
     358           43 :             id="prune-unused-images-button"
     359           43 :             component="button"
     360           43 :             className="pf-m-danger btn-delete"
     361            4 :             onClick={() => handlePruneUsedImages()}
     362           43 :             isDisabled={unusedImages.length === 0}
     363           43 :             isAriaDisabled={unusedImages.length === 0}
     364              :         >
     365           43 :             {_("Prune unused images")}
     366           43 :         </DropdownItem>
     367           43 :     ];
     368              : 
     369           43 :     return (
     370           43 :         <KebabDropdown
     371           43 :               toggleButtonId="image-actions-dropdown"
     372           43 :               position="right"
     373           43 :               dropdownItems={actions}
     374           43 :         />
     375              :     );
     376           43 : };
     377              : 
     378           43 : const ImageActions = ({ con, image, onAddNotification, users, downloadImage }) => {
     379           43 :     const Dialogs = useDialogs();
     380              : 
     381            9 :     const runImage = () => {
     382            9 :         Dialogs.show(
     383            9 :             <utils.PodmanInfoContext.Consumer>
     384            9 :                 {(podmanInfo) => (
     385            9 :                     <DialogsContext.Consumer>
     386            9 :                         {(Dialogs) => (
     387            9 :                             <ImageRunModal
     388            9 :                               users={users}
     389            9 :                               image={image}
     390            9 :                               onAddNotification={onAddNotification}
     391            9 :                               podmanInfo={podmanInfo}
     392            9 :                               dialogs={Dialogs}
     393            9 :                             />
     394              :                         )}
     395            9 :                     </DialogsContext.Consumer>
     396              :                 )}
     397            9 :             </utils.PodmanInfoContext.Consumer>);
     398            9 :     };
     399              : 
     400            1 :     const pullImage = () => {
     401            1 :         downloadImage(utils.image_name(image), null, con);
     402            1 :     };
     403              : 
     404            2 :     const removeImage = () => {
     405            2 :         Dialogs.show(<ImageDeleteModal con={con}
     406            2 :                                        imageWillDelete={image}
     407            2 :                                        onAddNotification={onAddNotification} />);
     408            2 :     };
     409              : 
     410           43 :     const runImageAction = (
     411           43 :         <Button key={image.Id + "create"}
     412           43 :                 className="ct-container-create show-only-when-wide"
     413           43 :                 variant='secondary'
     414            9 :                 onClick={ e => {
     415            9 :                     e.stopPropagation();
     416            9 :                     runImage();
     417            9 :                 }}
     418           43 :                 size="sm"
     419           43 :                 data-image={image.Id}>
     420           43 :             {_("Create container")}
     421           43 :         </Button>
     422              :     );
     423              : 
     424           43 :     const dropdownActions = [
     425           43 :         <DropdownItem key={image.Id + "create-menu"}
     426           43 :                     component="button"
     427           43 :                     className="show-only-when-narrow"
     428           43 :                     onClick={runImage}>
     429           43 :             {_("Create container")}
     430           43 :         </DropdownItem>,
     431           43 :         <DropdownItem key={image.Id + "pull"}
     432           43 :             component="button"
     433           43 :             onClick={pullImage}>
     434           43 :             {_("Pull")}
     435           43 :         </DropdownItem>,
     436           43 :         <DropdownItem key={image.Id + "delete"}
     437           43 :                     component="button"
     438           43 :                     className="pf-m-danger btn-delete"
     439           43 :                     onClick={removeImage}>
     440           43 :             {_("Delete")}
     441           43 :         </DropdownItem>
     442           43 :     ];
     443              : 
     444           43 :     return (
     445           43 :         <>
     446           43 :             {runImageAction}
     447           43 :             <KebabDropdown position="right" dropdownItems={dropdownActions} />
     448           43 :         </>
     449              :     );
     450           43 : };
     451              : 
     452           43 : export default Images;
        

Generated by: LCOV version 2.0-1