Line data Source code
1 43 : import React from 'react';
2 :
3 : import { Badge } from "@patternfly/react-core/dist/esm/components/Badge";
4 : import { Button } from "@patternfly/react-core/dist/esm/components/Button";
5 : import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card";
6 : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content";
7 : import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
8 : import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
9 : import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
10 : import { LabelGroup } from "@patternfly/react-core/dist/esm/components/Label";
11 : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover";
12 : import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/esm/components/Toolbar";
13 : import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip";
14 : import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
15 : import { MicrochipIcon, MemoryIcon, PortIcon, VolumeIcon, } from '@patternfly/react-icons';
16 : import { cellWidth, SortByDirection } from '@patternfly/react-table';
17 : import { KebabDropdown } from "cockpit-components-dropdown.jsx";
18 : import { useDialogs, DialogsContext } from "dialogs.jsx";
19 :
20 : import cockpit from 'cockpit';
21 : import { ListingPanel } from 'cockpit-components-listing-panel';
22 : import { ListingTable } from "cockpit-components-table";
23 : import * as machine_info from 'machine-info';
24 :
25 : import ContainerCheckpointModal from './ContainerCheckpointModal.jsx';
26 : import ContainerCommitModal from './ContainerCommitModal.jsx';
27 : import ContainerDeleteModal from './ContainerDeleteModal.jsx';
28 : import ContainerDetails from './ContainerDetails.jsx';
29 : import ContainerHealthLogs from './ContainerHealthLogs.jsx';
30 : import ContainerIntegration, { renderContainerPublishedPorts, renderContainerVolumes } from './ContainerIntegration.jsx';
31 : import ContainerLogs from './ContainerLogs.jsx';
32 : import ContainerRenameModal from './ContainerRenameModal.jsx';
33 : import ContainerRestoreModal from './ContainerRestoreModal.jsx';
34 : import ContainerTerminal from './ContainerTerminal.jsx';
35 : import ForceRemoveModal from './ForceRemoveModal.jsx';
36 : import { ImageRunModal } from './ImageRunModal.jsx';
37 : import { PodActions } from './PodActions.jsx';
38 : import { PodCreateModal } from './PodCreateModal.jsx';
39 : import PruneUnusedContainersModal from './PruneUnusedContainersModal.jsx';
40 : import * as client from './client.js';
41 : import * as utils from './util.js';
42 :
43 : import './Containers.scss';
44 : import '@patternfly/patternfly/utilities/Accessibility/accessibility.css';
45 :
46 43 : const _ = cockpit.gettext;
47 :
48 41 : const ContainerActions = ({ con, container, onAddNotification, localImages, updateContainer, isSystemdService, isDownloading }) => {
49 41 : const Dialogs = useDialogs();
50 41 : const isRunning = container.State.Status == "running";
51 41 : const isPaused = container.State.Status === "paused";
52 :
53 1 : const deleteContainer = () => {
54 1 : if (container.State.Status == "running") {
55 1 : const handleForceRemoveContainer = () => {
56 0 : const id = container ? container.Id : "";
57 :
58 1 : return client.delContainer(con, id, true)
59 0 : .catch(ex => {
60 0 : const error = cockpit.format(_("Failed to force remove container $0"), container.Name); // not-covered: OS error
61 0 : onAddNotification({ type: 'danger', error, errorDetail: ex.message });
62 0 : })
63 1 : .finally(() => {
64 1 : Dialogs.close();
65 1 : });
66 1 : };
67 :
68 1 : Dialogs.show(<ForceRemoveModal name={container.Name}
69 1 : handleForceRemove={handleForceRemoveContainer}
70 1 : reason={_("Deleting a running container will erase all data in it.")} />);
71 1 : } else {
72 1 : Dialogs.show(<ContainerDeleteModal con={con}
73 1 : containerWillDelete={container}
74 1 : onAddNotification={onAddNotification} />);
75 1 : }
76 1 : };
77 :
78 3 : const stopContainer = (force) => {
79 3 : const args = {};
80 :
81 3 : if (force)
82 3 : args.t = 0;
83 3 : client.postContainer(con, "stop", container.Id, args)
84 0 : .catch(ex => {
85 0 : const error = cockpit.format(_("Failed to stop container $0"), container.Name); // not-covered: OS error
86 0 : onAddNotification({ type: 'danger', error, errorDetail: ex.message });
87 0 : });
88 3 : };
89 :
90 4 : const startContainer = () => {
91 4 : client.postContainer(con, "start", container.Id, {})
92 0 : .catch(ex => {
93 0 : const error = cockpit.format(_("Failed to start container $0"), container.Name); // not-covered: OS error
94 0 : onAddNotification({ type: 'danger', error, errorDetail: ex.message });
95 0 : });
96 4 : };
97 :
98 2 : const resumeContainer = () => {
99 2 : client.postContainer(con, "unpause", container.Id, {})
100 0 : .catch(ex => {
101 0 : const error = cockpit.format(_("Failed to resume container $0"), container.Name); // not-covered: OS error
102 0 : onAddNotification({ type: 'danger', error, errorDetail: ex.message });
103 0 : });
104 2 : };
105 :
106 2 : const pauseContainer = () => {
107 2 : client.postContainer(con, "pause", container.Id, {})
108 0 : .catch(ex => {
109 0 : const error = cockpit.format(_("Failed to pause container $0"), container.Name); // not-covered: OS error
110 0 : onAddNotification({ type: 'danger', error, errorDetail: ex.message });
111 0 : });
112 2 : };
113 :
114 2 : const commitContainer = () => {
115 2 : Dialogs.show(<ContainerCommitModal con={con}
116 2 : container={container}
117 2 : localImages={localImages} />);
118 2 : };
119 :
120 2 : const restartContainer = (force) => {
121 2 : const args = {};
122 :
123 2 : if (force)
124 2 : args.t = 0;
125 2 : client.postContainer(con, "restart", container.Id, args)
126 0 : .catch(ex => {
127 0 : const error = cockpit.format(_("Failed to restart container $0"), container.Name); // not-covered: OS error
128 0 : onAddNotification({ type: 'danger', error, errorDetail: ex.message });
129 0 : });
130 2 : };
131 :
132 2 : const renameContainer = () => {
133 2 : if (container.State.Status !== "running") {
134 2 : Dialogs.show(<ContainerRenameModal con={con}
135 2 : container={container}
136 2 : updateContainer={updateContainer} />);
137 2 : }
138 2 : };
139 :
140 1 : const checkpointContainer = () => {
141 1 : Dialogs.show(<ContainerCheckpointModal con={con}
142 1 : containerWillCheckpoint={container}
143 1 : onAddNotification={onAddNotification} />);
144 1 : };
145 :
146 1 : const restoreContainer = () => {
147 1 : Dialogs.show(<ContainerRestoreModal con={con}
148 1 : containerWillRestore={container}
149 1 : onAddNotification={onAddNotification} />);
150 1 : };
151 :
152 38 : const addRenameAction = () => {
153 38 : actions.push(
154 38 : <DropdownItem key="rename"
155 2 : onClick={() => renameContainer()}>
156 38 : {_("Rename")}
157 38 : </DropdownItem>
158 38 : );
159 38 : };
160 :
161 41 : const actions = [];
162 23 : if (isRunning || isPaused) {
163 : // TODO: cockpit-podman currently isn't aware of podman quadlets as they don't keep containers around when stopped, failed or not yet started by default.
164 : // Allowing a user to stop or restart a container can result in the container disappearing from the list without a way to start it again. Until cockpit-podman
165 : // can list quadlets these actions are disabled.
166 30 : if (!isSystemdService) {
167 30 : actions.push(
168 30 : <DropdownItem key="stop"
169 2 : onClick={() => stopContainer()}>
170 30 : {_("Stop")}
171 30 : </DropdownItem>,
172 30 : <DropdownItem key="force-stop"
173 3 : onClick={() => stopContainer(true)}>
174 30 : {_("Force stop")}
175 30 : </DropdownItem>,
176 30 : <DropdownItem key="restart"
177 0 : onClick={() => restartContainer()}>
178 30 : {_("Restart")}
179 30 : </DropdownItem>,
180 30 : <DropdownItem key="force-restart"
181 2 : onClick={() => restartContainer(true)}>
182 30 : {_("Force restart")}
183 30 : </DropdownItem>
184 30 : );
185 :
186 30 : if (!isPaused) {
187 30 : actions.push(
188 30 : <DropdownItem key="pause"
189 2 : onClick={() => pauseContainer()}>
190 30 : {_("Pause")}
191 30 : </DropdownItem>
192 30 : );
193 3 : } else {
194 3 : actions.push(
195 3 : <DropdownItem key="resume"
196 2 : onClick={() => resumeContainer()}>
197 3 : {_("Resume")}
198 3 : </DropdownItem>
199 3 : );
200 3 : }
201 30 : }
202 :
203 21 : if (container.uid == 0 && !isPaused) {
204 21 : if (actions.length > 0)
205 18 : actions.push(<Divider key="separator-0" />);
206 :
207 21 : actions.push(
208 21 : <DropdownItem key="checkpoint"
209 1 : onClick={() => checkpointContainer()}>
210 21 : {_("Checkpoint")}
211 21 : </DropdownItem>
212 21 : );
213 21 : }
214 36 : }
215 :
216 26 : if (!isRunning && !isPaused) {
217 26 : actions.push(
218 26 : <DropdownItem key="start"
219 4 : onClick={() => startContainer()}>
220 26 : {_("Start")}
221 26 : </DropdownItem>
222 26 : );
223 26 : if (!isSystemdService) {
224 26 : addRenameAction();
225 26 : }
226 1 : if (container.uid == 0 && container.State?.CheckpointPath) {
227 1 : actions.push(
228 1 : <Divider key="separator-0" />,
229 1 : <DropdownItem key="restore"
230 1 : onClick={() => restoreContainer()}>
231 1 : {_("Restore")}
232 1 : </DropdownItem>
233 1 : );
234 1 : }
235 21 : } else { // running or paused
236 30 : if (!isSystemdService) {
237 30 : addRenameAction();
238 30 : }
239 36 : }
240 :
241 41 : actions.push(<Divider key="separator-1" />);
242 41 : actions.push(
243 41 : <DropdownItem key="commit"
244 2 : onClick={() => commitContainer()}>
245 41 : {_("Commit")}
246 41 : </DropdownItem>
247 41 : );
248 :
249 38 : if (!isSystemdService) {
250 38 : actions.push(<Divider key="separator-2" />);
251 38 : actions.push(
252 38 : <DropdownItem key="delete"
253 38 : className="pf-m-danger"
254 38 : onClick={deleteContainer}>
255 38 : {_("Delete")}
256 38 : </DropdownItem>
257 38 : );
258 38 : }
259 :
260 41 : return <KebabDropdown position="right" dropdownItems={actions} isDisabled={isDownloading} />;
261 41 : };
262 :
263 3 : export let onDownloadContainer = function funcOnDownloadContainer(container) {
264 3 : this.setState(prevState => ({
265 3 : downloadingContainers: [...prevState.downloadingContainers, container]
266 3 : }));
267 3 : };
268 :
269 2 : export let onDownloadContainerFinished = function funcOnDownloadContainerFinished(container) {
270 2 : this.setState(prevState => ({
271 2 : downloadingContainers: prevState.downloadingContainers.filter(entry => entry.name !== container.name),
272 2 : }));
273 2 : };
274 :
275 2 : const localize_health = (state) => {
276 2 : if (state === "healthy")
277 2 : return _("Healthy");
278 2 : else if (state === "unhealthy")
279 2 : return _("Unhealthy");
280 2 : else if (state === "starting")
281 0 : return _("Checking health");
282 : else
283 0 : console.error("Unexpected health check status", state);
284 0 : return null;
285 2 : };
286 :
287 43 : const ContainerOverActions = ({ handlePruneUnusedContainers, unusedContainers }) => {
288 43 : const actions = [
289 43 : <DropdownItem key="prune-unused-containers"
290 43 : id="prune-unused-containers-button"
291 43 : component="button"
292 43 : className="pf-m-danger btn-delete"
293 2 : onClick={() => handlePruneUnusedContainers()}
294 43 : isDisabled={unusedContainers.length === 0}>
295 43 : {_("Prune unused containers")}
296 43 : </DropdownItem>,
297 43 : ];
298 :
299 43 : return <KebabDropdown toggleButtonId="containers-actions-dropdown" position="right" dropdownItems={actions} />;
300 43 : };
301 :
302 43 : class Containers extends React.Component {
303 43 : static contextType = DialogsContext;
304 :
305 43 : constructor(props) {
306 43 : super(props);
307 43 : this.state = {
308 43 : width: 0,
309 43 : memTotal: 0,
310 43 : downloadingContainers: [],
311 43 : showPruneUnusedContainersModal: false,
312 43 : };
313 43 : this.renderRow = this.renderRow.bind(this);
314 43 : this.onWindowResize = this.onWindowResize.bind(this);
315 43 : this.podStats = this.podStats.bind(this);
316 :
317 43 : this.cardRef = React.createRef();
318 :
319 43 : onDownloadContainer = onDownloadContainer.bind(this);
320 43 : onDownloadContainerFinished = onDownloadContainerFinished.bind(this);
321 :
322 43 : machine_info.cpu_ram_info()
323 43 : .then(info => this.setState({ memTotal: info.memory }));
324 :
325 43 : window.addEventListener('resize', this.onWindowResize);
326 43 : }
327 :
328 43 : componentDidMount() {
329 43 : this.onWindowResize();
330 43 : }
331 :
332 1 : componentWillUnmount() {
333 1 : window.removeEventListener('resize', this.onWindowResize);
334 1 : }
335 :
336 41 : renderRow(containersStats, container, localImages) {
337 41 : const containerStats = containersStats[container.key];
338 41 : const image = container.ImageName;
339 21 : const isToolboxContainer = container.Config?.Labels?.["com.github.containers.toolbox"] === "true";
340 21 : const isDistroboxContainer = container.Config?.Labels?.manager === "distrobox";
341 21 : const isSystemdService = Boolean(container.Config?.Labels?.PODMAN_SYSTEMD_UNIT);
342 41 : let localized_health = null;
343 :
344 : // this needs to get along with stub containers from image run dialog, where most properties don't exist yet
345 : // HACK: Podman renamed `Healthcheck` to `Health` randomly
346 : // https://github.com/containers/podman/commit/119973375
347 0 : const healthcheck = container.State?.Health?.Status ?? container.State?.Healthcheck?.Status; // not-covered: only on old version
348 0 : const status = container.State?.Status ?? ""; // not-covered: race condition
349 :
350 41 : let proc = "";
351 41 : let mem = "";
352 0 : if (this.props.cgroupVersion == 'v1' && container.uid !== 0 && status == 'running') { // not-covered: only on old version
353 0 : proc = <div><abbr title={_("not available")}>{_("n/a")}</abbr></div>;
354 0 : mem = <div><abbr title={_("not available")}>{_("n/a")}</abbr></div>;
355 0 : }
356 24 : if (containerStats && status === "running") {
357 : // container.HostConfig.Memory (0 by default), containerStats.MemUsage
358 24 : if (containerStats.CPU != undefined)
359 24 : proc = <div className="ct-numeric-column">{containerStats.CPU.toFixed(2) + "%"}</div>;
360 24 : if (Number.isInteger(containerStats.MemUsage) && this.state.memTotal) {
361 : // the primary view is how much of the host's memory a container uses, for comparability
362 24 : const mem_pct = Math.round(containerStats.MemUsage / this.state.memTotal * 100);
363 24 : const mem_items = [
364 24 : <span key="pct">{cockpit.format("$0%", mem_pct)}</span>,
365 24 : <small key="abs">{cockpit.format_bytes(containerStats.MemUsage)}</small>
366 24 : ];
367 :
368 : // is there a configured limit?
369 4 : if (container.HostConfig?.Memory) {
370 4 : const limit_pct = Math.round(containerStats.MemUsage / container.HostConfig.Memory * 100);
371 4 : mem_items.push(
372 4 : <small key="limit">
373 4 : { cockpit.format(
374 4 : _("$0% of $1 limit"),
375 4 : limit_pct,
376 4 : cockpit.format_bytes(container.HostConfig.Memory)) }
377 4 : </small>
378 4 : );
379 4 : }
380 :
381 24 : mem = <div className="container-block ct-numeric-column">{mem_items}</div>;
382 24 : }
383 24 : }
384 41 : const info_block = (
385 41 : <div className="container-block">
386 41 : <Flex alignItems={{ default: 'alignItemsCenter' }}>
387 41 : <span className="container-name">{container.Name}</span>
388 1 : {isToolboxContainer && <Badge className='ct-badge-toolbox'>toolbox</Badge>}
389 1 : {isDistroboxContainer && <Badge className='ct-badge-distrobox'>distrobox</Badge>}
390 6 : {isSystemdService && <Badge className='ct-badge-service'>{_("service")}</Badge>}
391 41 : </Flex>
392 41 : <small>{image}</small>
393 41 : <small>{utils.quote_cmdline(container.Config?.Cmd)}</small>
394 41 : </div>
395 : );
396 :
397 41 : let containerStateClass = "ct-badge-container-" + status.toLowerCase();
398 41 : if (container.isDownloading)
399 3 : containerStateClass += " downloading";
400 :
401 41 : const containerState = status.charAt(0).toUpperCase() + status.slice(1);
402 :
403 41 : const state = [<Badge key={containerState} isRead className={containerStateClass}>{_(containerState)}</Badge>]; // States are defined in util.js
404 2 : if (healthcheck) {
405 2 : localized_health = localize_health(healthcheck);
406 2 : if (localized_health)
407 2 : state.push(<Badge key={healthcheck} isRead className={"ct-badge-container-" + healthcheck}>{localized_health}</Badge>);
408 2 : }
409 :
410 41 : const user = this.props.users.find(user => user.uid === container.uid);
411 41 : cockpit.assert(user, `User not found for container uid ${container.uid}`);
412 :
413 41 : const columns = [
414 0 : { title: info_block, sortKey: container.Name ?? container.Id },
415 41 : {
416 4 : title: (container.uid === 0) ? _("system") : <div><span className="ct-grey-text">{_("user:")} </span>{user.name}</div>,
417 41 : props: { modifier: "nowrap" },
418 41 : sortKey: container.key,
419 41 : },
420 17 : { title: proc, props: { modifier: "nowrap" }, sortKey: containerState === "Running" ? containerStats?.CPU ?? -1 : -1 },
421 24 : { title: mem, props: { modifier: "nowrap" }, sortKey: containerStats?.MemUsage ?? -1 },
422 41 : { title: <LabelGroup isVertical>{state}</LabelGroup>, sortKey: containerState },
423 41 : ];
424 :
425 41 : columns.push({
426 41 : title: <ContainerActions con={user.con}
427 41 : container={container}
428 41 : onAddNotification={this.props.onAddNotification}
429 41 : localImages={localImages}
430 41 : updateContainer={this.props.updateContainer}
431 41 : isSystemdService={isSystemdService}
432 41 : isDownloading={container.isDownloading} />,
433 41 : props: { className: "pf-v6-c-table__action" }
434 41 : });
435 :
436 41 : const tty = !!container.Config?.Tty;
437 :
438 41 : const tabs = [];
439 41 : if (container.State && user.con !== null) {
440 41 : tabs.push({
441 41 : name: _("Details"),
442 41 : renderer: ContainerDetails,
443 41 : data: { container }
444 41 : });
445 :
446 41 : if (!container.isDownloading) {
447 41 : tabs.push({
448 41 : name: _("Integration"),
449 41 : renderer: ContainerIntegration,
450 41 : data: { container, localImages }
451 41 : });
452 41 : tabs.push({
453 41 : name: _("Logs"),
454 41 : renderer: ContainerLogs,
455 41 : data: {
456 41 : containerId: container.Id,
457 41 : containerStatus: container.State.Status,
458 41 : width: this.state.width,
459 41 : uid: container.uid,
460 21 : systemd_unit: container.Config?.Labels?.PODMAN_SYSTEMD_UNIT,
461 41 : }
462 41 : });
463 41 : tabs.push({
464 41 : name: _("Console"),
465 41 : renderer: ContainerTerminal,
466 41 : data: { con: user.con, containerId: container.Id, containerStatus: container.State.Status, width: this.state.width, uid: container.uid, tty }
467 41 : });
468 41 : }
469 41 : }
470 :
471 2 : if (healthcheck) {
472 2 : tabs.push({
473 2 : name: _("Health check"),
474 2 : renderer: ContainerHealthLogs,
475 2 : data: { con: user.con, container, onAddNotification: this.props.onAddNotification, state: localized_health }
476 2 : });
477 2 : }
478 :
479 41 : return {
480 41 : expandedContent: <ListingPanel colSpan='4' tabRenderers={tabs} />,
481 41 : columns,
482 41 : initiallyExpanded: document.location.hash.substring(1) === container.Id,
483 41 : props: {
484 41 : key: container.key,
485 41 : "data-row-id": container.key,
486 41 : "data-started-at": container.State?.StartedAt,
487 41 : },
488 41 : };
489 41 : }
490 :
491 43 : onWindowResize() {
492 43 : this.setState({ width: this.cardRef.current.clientWidth });
493 43 : }
494 :
495 10 : podStats(pod) {
496 10 : const { containersStats } = this.props;
497 : // when no containers exists pod.Containers is null
498 0 : if (!containersStats || !pod.Containers) {
499 0 : return null;
500 0 : }
501 :
502 : // As podman does not provide per pod memory/cpu statistics we do the following:
503 : // - add up CPU usage to display total CPU use of all containers in the pod
504 : // - add up memory usage so it displays the total memory of the pod.
505 10 : let cpu = 0;
506 10 : let mem = 0;
507 8 : for (const container of pod.Containers) {
508 8 : const containerStats = containersStats[utils.makeKey(pod.uid, container.Id)];
509 8 : if (!containerStats)
510 8 : continue;
511 :
512 4 : if (containerStats.CPU != undefined) {
513 4 : cpu += containerStats.CPU;
514 4 : }
515 4 : if (containerStats.MemUsage != undefined) {
516 4 : mem += containerStats.MemUsage;
517 4 : }
518 8 : }
519 :
520 10 : return {
521 10 : cpu: cpu.toFixed(2),
522 10 : mem,
523 10 : };
524 10 : }
525 :
526 10 : renderPodDetails(pod, podStatus) {
527 10 : const podStats = this.podStats(pod);
528 10 : const infraContainer = this.props.containers[utils.makeKey(pod.uid, pod.InfraId)];
529 4 : const numPorts = Object.keys(infraContainer?.NetworkSettings?.Ports ?? {}).length;
530 :
531 10 : return (
532 10 : <>
533 10 : {podStats && podStatus === "Running" &&
534 6 : <>
535 6 : <Flex className='pod-stat' spaceItems={{ default: 'spaceItemsSm' }}>
536 6 : <Tooltip content={_("CPU")}>
537 6 : <MicrochipIcon />
538 6 : </Tooltip>
539 6 : <Content component={ContentVariants.p} className="pf-v6-u-hidden-on-sm">{_("CPU")}</Content>
540 6 : <Content component={ContentVariants.p} className="pod-cpu">{podStats.cpu}%</Content>
541 6 : </Flex>
542 6 : <Flex className='pod-stat' spaceItems={{ default: 'spaceItemsSm' }}>
543 6 : <Tooltip content={_("Memory")}>
544 6 : <MemoryIcon />
545 6 : </Tooltip>
546 6 : <Content component={ContentVariants.p} className="pf-v6-u-hidden-on-sm">{_("Memory")}</Content>
547 6 : <Content component={ContentVariants.p} className="pod-memory">{cockpit.format_bytes(podStats.mem)}</Content>
548 6 : </Flex>
549 6 : </>
550 : }
551 10 : {infraContainer &&
552 7 : <>
553 7 : {numPorts > 0 &&
554 3 : <Tooltip content={_("Click to see published ports")}>
555 3 : <Popover
556 3 : enableFlip
557 3 : bodyContent={renderContainerPublishedPorts(infraContainer.NetworkSettings.Ports)}
558 : >
559 3 : <Button size="sm" variant="link" className="pod-details-button pod-details-ports-btn"
560 3 : icon={<PortIcon className="pod-details-button-color" />}
561 : >
562 3 : {numPorts}
563 3 : <Content component={ContentVariants.p} className="pf-v6-u-hidden-on-sm">{_("ports")}</Content>
564 3 : </Button>
565 3 : </Popover>
566 3 : </Tooltip>
567 : }
568 7 : {infraContainer.Mounts && infraContainer.Mounts.length !== 0 &&
569 3 : <Tooltip content={_("Click to see volumes")}>
570 3 : <Popover
571 3 : enableFlip
572 3 : bodyContent={renderContainerVolumes(infraContainer.Mounts)}
573 : >
574 3 : <Button size="sm" variant="link" className="pod-details-button pod-details-volumes-btn"
575 3 : icon={<VolumeIcon className="pod-details-button-color" />}
576 : >
577 3 : {infraContainer.Mounts.length}
578 3 : <Content component={ContentVariants.p} className="pf-v6-u-hidden-on-sm">{_("volumes")}</Content>
579 3 : </Button>
580 3 : </Popover>
581 3 : </Tooltip>
582 : }
583 7 : </>
584 : }
585 10 : </>
586 : );
587 10 : }
588 :
589 2 : onOpenPruneUnusedContainersDialog = () => {
590 2 : this.setState({ showPruneUnusedContainersModal: true });
591 2 : };
592 :
593 43 : render() {
594 43 : const Dialogs = this.context;
595 43 : const columnTitles = [
596 43 : { title: _("Container"), transforms: [cellWidth(20)], sortable: true },
597 43 : { title: _("Owner"), sortable: true },
598 43 : { title: _("CPU"), sortable: true, props: { className: 'ct-numeric-column' } },
599 43 : { title: _("Memory"), sortable: true, props: { className: 'ct-numeric-column' } },
600 43 : { title: _("State"), sortable: true },
601 43 : { title: "", sortable: false, props: { screenReaderText: _("Actions") } },
602 43 : ];
603 43 : const partitionedContainers = { 'no-pod': [] };
604 43 : let filtered = [];
605 43 : const unusedContainers = [];
606 :
607 43 : let emptyCaption = _("No containers");
608 43 : const emptyCaptionPod = _("No containers in this pod");
609 43 : if (this.props.containers === null || this.props.pods === null)
610 43 : emptyCaption = _("Loading...");
611 43 : else if (this.props.textFilter.length > 0)
612 3 : emptyCaption = _("No containers that match the current filter");
613 43 : else if (this.props.filter == "running")
614 4 : emptyCaption = _("No running containers");
615 :
616 43 : if (this.props.containers !== null && this.props.pods !== null) {
617 4 : filtered = Object.keys(this.props.containers).filter(id => !(this.props.filter == "running") || ["running", "restarting"].includes(this.props.containers[id].State.Status));
618 :
619 1 : if (this.props.ownerFilter !== "all") {
620 1 : filtered = filtered.filter(id => {
621 1 : if (this.props.ownerFilter === "user")
622 0 : return this.props.containers[id].uid === null;
623 1 : return this.props.containers[id].uid === this.props.ownerFilter;
624 1 : });
625 1 : }
626 :
627 3 : if (this.props.textFilter.length > 0) {
628 3 : const lcf = this.props.textFilter.toLowerCase();
629 3 : filtered = filtered.filter(id => this.props.containers[id].Name.toLowerCase().indexOf(lcf) >= 0 ||
630 2 : (this.props.containers[id].Pod &&
631 0 : this.props.pods[utils.makeKey(this.props.containers[id].uid, this.props.containers[id].Pod)].Name.toLowerCase().indexOf(lcf) >= 0) ||
632 2 : this.props.containers[id].ImageName.toLowerCase().indexOf(lcf) >= 0
633 3 : );
634 3 : }
635 :
636 : // Remove infra containers
637 41 : filtered = filtered.filter(id => !this.props.containers[id].IsInfra);
638 :
639 28 : const getHealth = id => {
640 28 : const state = this.props.containers[id]?.State;
641 0 : return state?.Health?.Status || state?.Healthcheck?.Status;
642 28 : };
643 :
644 28 : filtered.sort((a, b) => {
645 : // Show unhealthy containers first
646 28 : const a_health = getHealth(a);
647 28 : const b_health = getHealth(b);
648 2 : if (a_health !== b_health) {
649 2 : if (a_health === "unhealthy")
650 2 : return -1;
651 2 : if (b_health === "unhealthy")
652 2 : return 1;
653 2 : }
654 : // User containers are in front of system ones
655 28 : if (this.props.containers[a].uid !== this.props.containers[b].uid)
656 0 : return (this.props.containers[a].uid === 0) ? 1 : -1;
657 10 : return this.props.containers[a].Name > this.props.containers[b].Name ? 1 : -1;
658 28 : });
659 :
660 0 : Object.keys(this.props.pods || {}).forEach(pod => { partitionedContainers[pod] = [] });
661 :
662 41 : filtered.forEach(id => {
663 41 : const container = this.props.containers[id];
664 41 : if (container)
665 2 : (partitionedContainers[container.Pod ? utils.makeKey(container.uid, container.Pod) : 'no-pod'] || []).push(container);
666 41 : });
667 :
668 : // Append downloading containers
669 3 : this.state.downloadingContainers.forEach(cont => {
670 3 : partitionedContainers['no-pod'].push(cont);
671 3 : });
672 :
673 : // Apply filters to pods
674 43 : Object.keys(partitionedContainers).forEach(section => {
675 43 : const lcf = this.props.textFilter.toLowerCase();
676 10 : if (section != "no-pod") {
677 10 : const pod = this.props.pods[section];
678 1 : if ((this.props.filter == "running" && pod.Status != "Running") ||
679 : // If nor the pod name nor any container inside the pod fit the filter, hide the whole pod
680 8 : (!partitionedContainers[section].length && pod.Name.toLowerCase().indexOf(lcf) < 0) ||
681 10 : (this.props.ownerFilter !== "all" &&
682 0 : ((this.props.ownerFilter === "user" && pod.uid !== null) ||
683 0 : (this.props.ownerFilter !== "user" && pod.uid !== this.props.ownerFilter))))
684 1 : delete partitionedContainers[section];
685 10 : }
686 43 : });
687 : // If there are pods to show and the generic container list is empty don't show it at all
688 10 : if (Object.keys(partitionedContainers).length > 1 && !partitionedContainers["no-pod"].length)
689 7 : delete partitionedContainers["no-pod"];
690 :
691 43 : const prune_states = ["created", "configured", "stopped", "exited"];
692 41 : for (const containerid of Object.keys(this.props.containers)) {
693 41 : const container = this.props.containers[containerid];
694 : // Ignore pods and running containers
695 26 : if (!prune_states.includes(container.State.Status) || container.Pod)
696 41 : continue;
697 :
698 23 : unusedContainers.push({
699 23 : id: container.Id,
700 23 : name: container.Name,
701 23 : key: container.key,
702 23 : created: container.Created,
703 23 : uid: container.uid,
704 23 : });
705 23 : }
706 43 : }
707 :
708 : // Convert to the search result output
709 43 : let localImages = null;
710 43 : let nonIntermediateImages = null;
711 43 : if (this.props.images) {
712 43 : localImages = Object.keys(this.props.images).map(id => {
713 43 : const img = this.props.images[id];
714 4 : img.Index = img.RepoTags?.[0] ? img.RepoTags[0].split('/')[0] : "";
715 43 : img.Name = utils.image_name(img);
716 7 : img.toString = function imgToString() { return this.Name };
717 43 : return img;
718 43 : }, []);
719 43 : nonIntermediateImages = localImages.filter(img => img.Index !== "");
720 43 : }
721 :
722 7 : const createContainer = (inPod) => {
723 7 : if (nonIntermediateImages)
724 7 : Dialogs.show(
725 7 : <utils.PodmanInfoContext.Consumer>
726 7 : {(podmanInfo) => (
727 7 : <DialogsContext.Consumer>
728 7 : {(Dialogs) => (
729 7 : <ImageRunModal users={this.props.users}
730 7 : localImages={nonIntermediateImages}
731 7 : pod={inPod}
732 7 : onAddNotification={this.props.onAddNotification}
733 7 : podmanInfo={podmanInfo}
734 7 : dialogs={Dialogs} />
735 : )}
736 7 : </DialogsContext.Consumer>
737 : )}
738 7 : </utils.PodmanInfoContext.Consumer>);
739 7 : };
740 :
741 2 : const createPod = () => {
742 2 : Dialogs.show(<PodCreateModal
743 2 : users={this.props.users}
744 2 : onAddNotification={this.props.onAddNotification} />);
745 2 : };
746 :
747 43 : const filterRunning = (
748 43 : <Toolbar>
749 43 : <ToolbarContent className="containers-containers-toolbarcontent">
750 43 : <ToolbarItem alignSelf="center" variant="label" htmlFor="containers-containers-filter">
751 43 : {_("Show")}
752 43 : </ToolbarItem>
753 43 : <ToolbarItem>
754 17 : <FormSelect id="containers-containers-filter" value={this.props.filter} onChange={(_, value) => this.props.handleFilterChange(value)}>
755 43 : <FormSelectOption value='all' label={_("All")} />
756 43 : <FormSelectOption value='running' label={_("Only running")} />
757 43 : </FormSelect>
758 43 : </ToolbarItem>
759 43 : <Divider orientation={{ default: "vertical" }} />
760 43 : <ToolbarItem>
761 43 : <Button variant="secondary" key="create-new-pod-action"
762 43 : id="containers-containers-create-pod-btn"
763 2 : onClick={() => createPod()}>
764 43 : {_("Create pod")}
765 43 : </Button>
766 43 : </ToolbarItem>
767 43 : <ToolbarItem>
768 43 : <Button variant="primary" key="get-new-image-action"
769 43 : id="containers-containers-create-container-btn"
770 43 : isDisabled={nonIntermediateImages === null}
771 5 : onClick={() => createContainer(null)}>
772 43 : {_("Create container")}
773 43 : </Button>
774 43 : </ToolbarItem>
775 43 : <ToolbarItem>
776 43 : <ContainerOverActions unusedContainers={unusedContainers} handlePruneUnusedContainers={this.onOpenPruneUnusedContainersDialog} />
777 43 : </ToolbarItem>
778 43 : </ToolbarContent>
779 43 : </Toolbar>
780 : );
781 :
782 41 : const sortRows = (rows, direction, idx) => {
783 : // CPU / Memory /States
784 41 : const isNumeric = idx == 2 || idx == 3 || idx == 4;
785 41 : const stateOrderMapping = {};
786 41 : utils.states.forEach((elem, index) => {
787 41 : stateOrderMapping[elem] = index;
788 41 : });
789 26 : const sortedRows = rows.sort((a, b) => {
790 0 : let aitem = a.columns[idx].sortKey ?? a.columns[idx].title;
791 0 : let bitem = b.columns[idx].sortKey ?? b.columns[idx].title;
792 : // Sort the states based on the order defined in utils. so Running first.
793 0 : if (idx === 4) {
794 0 : aitem = stateOrderMapping[aitem];
795 0 : bitem = stateOrderMapping[bitem];
796 0 : }
797 0 : if (isNumeric) {
798 0 : return bitem - aitem;
799 0 : } else {
800 26 : return aitem.localeCompare(bitem);
801 26 : }
802 26 : });
803 0 : return direction === SortByDirection.asc ? sortedRows : sortedRows.reverse();
804 41 : };
805 :
806 43 : const card = (
807 43 : <Card id="containers-containers" className="containers-containers">
808 43 : <CardHeader actions={{ actions: filterRunning }}>
809 43 : <CardTitle><Content component={ContentVariants.h1}>{_("Containers")}</Content></CardTitle>
810 43 : </CardHeader>
811 43 : <CardBody>
812 43 : <Flex direction={{ default: 'column' }}>
813 43 : {(this.props.containers === null || this.props.pods === null)
814 43 : ? <ListingTable variant='compact'
815 43 : aria-label={_("Containers")}
816 43 : emptyCaption={emptyCaption}
817 43 : columns={columnTitles}
818 43 : sortMethod={sortRows}
819 43 : rows={[]}
820 43 : sortBy={{ index: 0, direction: SortByDirection.asc }} />
821 43 : : Object.keys(partitionedContainers)
822 7 : .sort((a, b) => {
823 0 : if (a == "no-pod") return -1;
824 5 : else if (b == "no-pod") return 1;
825 :
826 : // User pods are in front of system ones
827 2 : if (this.props.pods[a].uid !== this.props.pods[b].uid)
828 0 : return this.props.pods[a].uid === 0 ? 1 : -1;
829 0 : return this.props.pods[a].Name > this.props.pods[b].Name ? 1 : -1;
830 7 : })
831 43 : .map(section => {
832 43 : const tableProps = {};
833 41 : const rows = partitionedContainers[section].map(container => {
834 41 : return this.renderRow(this.props.containersStats, container,
835 41 : localImages);
836 41 : });
837 43 : let caption;
838 43 : let podStatus;
839 43 : let pod;
840 43 : let isPodService = false;
841 43 : let con;
842 10 : if (section !== 'no-pod') {
843 10 : pod = this.props.pods[section];
844 10 : con = this.props.users.find(u => u.uid === pod.uid).con;
845 10 : tableProps['aria-label'] = cockpit.format("Containers of pod $0", pod.Name);
846 10 : podStatus = pod.Status;
847 10 : isPodService = Boolean(pod.Labels?.PODMAN_SYSTEMD_UNIT);
848 10 : caption = pod.Name;
849 8 : } else {
850 41 : tableProps['aria-label'] = _("Containers");
851 41 : }
852 :
853 43 : const actions = caption && (
854 10 : <>
855 10 : <Badge isRead className={"ct-badge-pod-" + podStatus.toLowerCase()}>{_(podStatus)}</Badge>
856 10 : {!isPodService &&
857 8 : <Button variant="secondary"
858 8 : className="create-container-in-pod"
859 8 : isDisabled={nonIntermediateImages === null}
860 2 : onClick={() => createContainer(this.props.pods[section])}>
861 8 : {_("Create container in pod")}
862 8 : </Button>}
863 10 : <PodActions con={con}
864 10 : onAddNotification={this.props.onAddNotification}
865 10 : pod={pod}
866 10 : isPodService={isPodService}
867 10 : />
868 10 : </>
869 : );
870 43 : return (
871 43 : <Card key={'table-' + section}
872 8 : id={'table-' + (section == "no-pod" ? section : this.props.pods[section].Name)}
873 43 : isPlain={section == "no-pod"}
874 43 : className="container-pod"
875 43 : isClickable
876 43 : isSelectable>
877 10 : {caption && <CardHeader actions={{ actions, className: "panel-actions" }}>
878 10 : <CardTitle>
879 10 : <Flex justifyContent={{ default: 'justifyContentFlexStart' }}>
880 10 : <h3 className='pod-name'>{caption}</h3>
881 10 : <span>{_("pod")}</span>
882 2 : {isPodService && <Badge className='ct-badge-service'>{_("service")}</Badge>}
883 10 : {this.renderPodDetails(this.props.pods[section], podStatus)}
884 10 : </Flex>
885 10 : </CardTitle>
886 10 : </CardHeader>}
887 43 : <ListingTable variant='compact'
888 8 : emptyCaption={section == "no-pod" ? emptyCaption : emptyCaptionPod}
889 43 : columns={columnTitles}
890 43 : sortMethod={sortRows}
891 43 : rows={rows}
892 43 : {...tableProps} />
893 43 : </Card>
894 : );
895 43 : })}
896 43 : </Flex>
897 43 : {this.state.showPruneUnusedContainersModal &&
898 2 : <PruneUnusedContainersModal
899 2 : close={() => this.setState({ showPruneUnusedContainersModal: false })}
900 2 : unusedContainers={unusedContainers}
901 2 : onAddNotification={this.props.onAddNotification}
902 2 : users={this.props.users} /> }
903 43 : </CardBody>
904 43 : </Card>
905 : );
906 :
907 43 : return <div ref={this.cardRef}>{card}</div>;
908 43 : }
909 43 : }
910 :
911 43 : export default Containers;
|