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;
|