Line data Source code
1 43 : import React, { useState } from 'react';
2 :
3 : import { Button } from "@patternfly/react-core/dist/esm/components/Button";
4 : import { DataList, DataListCell, DataListItem, DataListItemCells, DataListItemRow } from "@patternfly/react-core/dist/esm/components/DataList";
5 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
6 : import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
7 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
8 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
9 : import {
10 : Modal
11 : } from '@patternfly/react-core/dist/esm/deprecated/components/Modal';
12 : import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
13 : import { ExclamationCircleIcon } from '@patternfly/react-icons';
14 : import { useDialogs } from "dialogs.jsx";
15 :
16 : import cockpit from 'cockpit';
17 : import { EmptyStatePanel } from "cockpit-components-empty-state.tsx";
18 :
19 : import { ErrorNotification } from './Notification.jsx';
20 : import * as client from './client.js';
21 : import rest from './rest.js';
22 : import { fallbackRegistries, usePodmanInfo } from './util.js';
23 :
24 : import './ImageSearchModal.css';
25 :
26 43 : const _ = cockpit.gettext;
27 :
28 1 : export const ImageSearchModal = ({ downloadImage, users }) => {
29 1 : const [searchInProgress, setSearchInProgress] = useState(false);
30 1 : const [searchFinished, setSearchFinished] = useState(false);
31 1 : const [imageIdentifier, setImageIdentifier] = useState('');
32 1 : const [imageList, setImageList] = useState([]);
33 1 : const [imageTag, setImageTag] = useState("");
34 1 : const [user, setUser] = useState(users[0]);
35 1 : const [selectedRegistry, setSelectedRegistry] = useState("");
36 1 : const [selected, setSelected] = useState("");
37 1 : const [dialogError, setDialogError] = useState("");
38 1 : const [dialogErrorDetail, setDialogErrorDetail] = useState("");
39 1 : const [typingTimeout, setTypingTimeout] = useState(null);
40 :
41 1 : let activeConnection = null;
42 1 : const { registries } = usePodmanInfo();
43 1 : const Dialogs = useDialogs();
44 : // Registries to use for searching
45 0 : const searchRegistries = registries.search && registries.length !== 0 ? registries.search : fallbackRegistries;
46 :
47 : // Don't use on selectedRegistry state variable for finding out the
48 : // registry to search in as with useState we can only call something after a
49 : // state update with useEffect but as onSearchTriggered also changes state we
50 : // can't use that so instead we pass the selected registry.
51 1 : const onSearchTriggered = (searchRegistry = "", forceSearch = false) => {
52 : // When search re-triggers close any existing active connection
53 1 : activeConnection = rest.connect(user.uid);
54 1 : if (activeConnection)
55 1 : activeConnection.close();
56 1 : setSearchFinished(false);
57 :
58 : // Do not call the SearchImage API if the input string is not at least 2 chars,
59 : // unless Enter is pressed, which should force start the search.
60 : // The comparison was done considering the fact that we miss always one letter due to delayed setState
61 1 : if (imageIdentifier.length < 2 && !forceSearch)
62 1 : return;
63 :
64 1 : setSearchInProgress(true);
65 :
66 1 : let queryRegistries = searchRegistries;
67 1 : if (searchRegistry !== "") {
68 1 : queryRegistries = [searchRegistry];
69 1 : }
70 : // if a user searches for `docker.io/cockpit` let podman search in the user specified registry.
71 0 : if (imageIdentifier.includes('/')) {
72 0 : queryRegistries = [""];
73 0 : }
74 :
75 1 : const searches = queryRegistries.map(rr => {
76 0 : const registry = rr.length < 1 || rr[rr.length - 1] === "/" ? rr : rr + "/";
77 1 : return activeConnection.call({
78 1 : method: "GET",
79 1 : path: client.VERSION + "libpod/images/search",
80 1 : body: "",
81 1 : params: {
82 1 : term: registry + imageIdentifier
83 1 : }
84 1 : });
85 1 : });
86 :
87 1 : Promise.allSettled(searches)
88 1 : .then(reply => {
89 1 : if (reply) {
90 1 : let results = [];
91 :
92 1 : for (const result of reply) {
93 1 : if (result.status === "fulfilled") {
94 1 : results = results.concat(JSON.parse(result.value));
95 0 : } else {
96 0 : setDialogError(_("Failed to search for new images"));
97 0 : setDialogErrorDetail(result.reason ? cockpit.format(_("Failed to search for images: $0"), result.reason.message) : _("Failed to search for images."));
98 0 : }
99 1 : }
100 :
101 0 : setImageList(results || []);
102 1 : setSearchInProgress(false);
103 1 : setSearchFinished(true);
104 1 : }
105 1 : });
106 1 : };
107 :
108 1 : const onKeyDown = (e) => {
109 1 : if (e.key != ' ') { // Space should not trigger search
110 1 : const forceSearch = e.key == 'Enter';
111 0 : if (forceSearch) {
112 0 : e.preventDefault();
113 0 : }
114 :
115 : // Reset the timer, to make the http call after 250MS
116 1 : clearTimeout(typingTimeout);
117 1 : setTypingTimeout(setTimeout(() => onSearchTriggered(selectedRegistry, forceSearch), 250));
118 1 : }
119 1 : };
120 :
121 1 : const onToggleUser = ev => setUser(users.find(u => u.name === ev.currentTarget.value));
122 1 : const onDownloadClicked = () => {
123 1 : const selectedImageName = imageList[selected].Name;
124 1 : if (activeConnection)
125 0 : activeConnection.close();
126 1 : Dialogs.close();
127 1 : downloadImage(selectedImageName, imageTag, user.con);
128 1 : };
129 :
130 1 : const handleClose = () => {
131 1 : if (activeConnection)
132 0 : activeConnection.close();
133 1 : Dialogs.close();
134 1 : };
135 :
136 1 : return (
137 1 : <Modal isOpen className="podman-search"
138 1 : position="top" variant="large"
139 1 : onClose={handleClose}
140 1 : title={_("Search for an image")}
141 1 : footer={<>
142 1 : <Form isHorizontal className="image-search-tag-form">
143 1 : <FormGroup fieldId="image-search-tag" label={_("Tag")}>
144 1 : <TextInput className="image-tag-entry"
145 1 : id="image-search-tag"
146 1 : type='text'
147 1 : placeholder="latest"
148 1 : value={imageTag || ''}
149 1 : onChange={(_event, value) => setImageTag(value)} />
150 1 : </FormGroup>
151 1 : </Form>
152 1 : <Button variant='primary' isDisabled={selected === ""} onClick={onDownloadClicked}>
153 1 : {_("Download")}
154 1 : </Button>
155 1 : <Button variant='link' className='btn-cancel' onClick={handleClose}>
156 1 : {_("Cancel")}
157 1 : </Button>
158 1 : </>}
159 : >
160 1 : <Form isHorizontal>
161 0 : {dialogError && <ErrorNotification errorMessage={dialogError} errorDetail={dialogErrorDetail} />}
162 1 : { users.length > 1 &&
163 1 : <FormGroup id="as-user" label={_("Owner")} isInline>
164 1 : { users.map(u => (
165 1 : <Radio key={u.name}
166 1 : value={u.name}
167 1 : label={u.name}
168 1 : id={"image-search-modal-owner-" + u.name}
169 1 : onChange={onToggleUser}
170 1 : isChecked={u === user} />))
171 : }
172 1 : </FormGroup>}
173 1 : <Flex spaceItems={{ default: 'inlineFlex', modifier: 'spaceItemsXl' }}>
174 1 : <FormGroup fieldId="search-image-dialog-name" label={_("Search for")}>
175 1 : <TextInput id='search-image-dialog-name'
176 1 : type='text'
177 1 : placeholder={_("Search by name or description")}
178 1 : value={imageIdentifier}
179 1 : onKeyDown={onKeyDown}
180 1 : onChange={(_event, value) => setImageIdentifier(value)} />
181 1 : </FormGroup>
182 1 : <FormGroup fieldId="registry-select" label={_("in")}>
183 1 : <FormSelect id='registry-select'
184 1 : value={selectedRegistry}
185 1 : onChange={(_ev, value) => { setSelectedRegistry(value); clearTimeout(typingTimeout); onSearchTriggered(value, false) }}>
186 1 : <FormSelectOption value="" key="all" label={_("All registries")} />
187 0 : {(searchRegistries || []).map(r => <FormSelectOption value={r} key={r} label={r} />)}
188 1 : </FormSelect>
189 1 : </FormGroup>
190 1 : </Flex>
191 1 : </Form>
192 :
193 1 : {searchInProgress && <EmptyStatePanel loading title={_("Searching...")} /> }
194 :
195 1 : {((!searchInProgress && !searchFinished) || imageIdentifier == "") && <EmptyStatePanel title={_("No images found")} paragraph={_("Start typing to look for images.")} /> }
196 :
197 1 : {searchFinished && imageIdentifier !== '' && <>
198 1 : {imageList.length == 0 && <EmptyStatePanel icon={ExclamationCircleIcon}
199 1 : title={cockpit.format(_("No results for $0"), imageIdentifier)}
200 1 : paragraph={_("Retry another term.")}
201 1 : />}
202 1 : {imageList.length > 0 &&
203 1 : <DataList isCompact
204 1 : selectedDataListItemId={"image-list-item-" + selected}
205 1 : onSelectDataListItem={(_, key) => setSelected(key.split('-').slice(-1)[0])}>
206 1 : {imageList.map((image, iter) => {
207 1 : return (
208 1 : <DataListItem id={"image-list-item-" + iter} key={iter}>
209 1 : <DataListItemRow>
210 1 : <DataListItemCells
211 1 : dataListCells={[
212 1 : <DataListCell key="primary content">
213 1 : <span className='image-name'>{image.Name}</span>
214 1 : </DataListCell>,
215 1 : <DataListCell key="secondary content" wrapModifier="truncate">
216 1 : <span className='image-description'>{image.Description}</span>
217 1 : </DataListCell>
218 1 : ]}
219 1 : />
220 1 : </DataListItemRow>
221 1 : </DataListItem>
222 : );
223 1 : })}
224 1 : </DataList>}
225 1 : </>}
226 1 : </Modal>
227 : );
228 1 : };
|