Line data Source code
1 : /*
2 : * This file is part of Cockpit.
3 : *
4 : * Copyright (C) 2017 Red Hat, Inc.
5 : *
6 : * Cockpit is free software; you can redistribute it and/or modify it
7 : * under the terms of the GNU Lesser General Public License as published by
8 : * the Free Software Foundation; either version 2.1 of the License, or
9 : * (at your option) any later version.
10 : *
11 : * Cockpit is distributed in the hope that it will be useful, but
12 : * WITHOUT ANY WARRANTY; without even the implied warranty of
13 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 : * Lesser General Public License for more details.
15 : *
16 : * You should have received a copy of the GNU Lesser General Public License
17 : * along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
18 : */
19 :
20 43 : import React from 'react';
21 :
22 : import { Alert, AlertActionCloseButton, AlertGroup } from "@patternfly/react-core/dist/esm/components/Alert";
23 : import { Button } from "@patternfly/react-core/dist/esm/components/Button";
24 : import { EmptyState, EmptyStateFooter, EmptyStateActions, EmptyStateVariant } from "@patternfly/react-core/dist/esm/components/EmptyState";
25 : import { Page, PageSection, } from "@patternfly/react-core/dist/esm/components/Page";
26 : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack";
27 : import { ExclamationCircleIcon } from '@patternfly/react-icons';
28 : import { WithDialogs } from "dialogs.jsx";
29 :
30 : import cockpit from 'cockpit';
31 : import { superuser } from "superuser";
32 :
33 : import ContainerHeader from './ContainerHeader.jsx';
34 : import Containers from './Containers.jsx';
35 : import Images from './Images.jsx';
36 : import * as client from './client.js';
37 : import rest from './rest.js';
38 : import { makeKey, WithPodmanInfo, debug } from './util.js';
39 :
40 43 : const _ = cockpit.gettext;
41 :
42 : // sort order of "users" state for dialogs: system, session user, then other users by ascending name
43 27 : function compareUser(a, b) {
44 27 : if (a.uid === 0)
45 27 : return -1;
46 27 : if (b.uid === 0)
47 27 : return 1;
48 1 : if (a.uid === null)
49 1 : return -1;
50 1 : if (b.uid === null)
51 1 : return 1;
52 1 : return a.name.localeCompare(b.name);
53 27 : }
54 :
55 43 : class Application extends React.Component {
56 43 : constructor(props) {
57 43 : super(props);
58 43 : this.state = {
59 : // currently connected services per user: { con, uid, name, imagesLoaded, containersLoaded, podsLoaded }
60 : // start with dummy state to wait for initialization
61 43 : users: [{ con: null, uid: 0, name: _("system") }, { con: null, uid: null, name: _("user") }],
62 43 : images: null,
63 43 : containers: null,
64 43 : containersFilter: "all",
65 43 : containersStats: {},
66 43 : textFilter: "",
67 43 : ownerFilter: "all",
68 43 : dropDownValue: 'Everything',
69 43 : notifications: [],
70 43 : version: '1.3.0',
71 43 : selinuxAvailable: false,
72 43 : podmanRestartAvailable: false,
73 43 : userPodmanRestartAvailable: false,
74 43 : userLingeringEnabled: null,
75 43 : location: {},
76 43 : };
77 43 : this.onAddNotification = this.onAddNotification.bind(this);
78 43 : this.onDismissNotification = this.onDismissNotification.bind(this);
79 43 : this.onFilterChanged = this.onFilterChanged.bind(this);
80 43 : this.onOwnerChanged = this.onOwnerChanged.bind(this);
81 43 : this.onContainerFilterChanged = this.onContainerFilterChanged.bind(this);
82 43 : this.updateContainer = this.updateContainer.bind(this);
83 43 : this.goToServicePage = this.goToServicePage.bind(this);
84 43 : this.onNavigate = this.onNavigate.bind(this);
85 :
86 43 : this.pendingUpdateContainer = {}; // key (uid-id) → promise
87 43 : }
88 :
89 1 : onAddNotification(notification) {
90 1 : notification.index = this.state.notifications.length;
91 :
92 1 : this.setState(prevState => ({
93 1 : notifications: [
94 1 : ...prevState.notifications,
95 1 : notification
96 1 : ]
97 1 : }));
98 1 : }
99 :
100 0 : onDismissNotification(notificationIndex) {
101 0 : const notificationsArray = this.state.notifications.concat();
102 0 : const index = notificationsArray.findIndex(current => current.index == notificationIndex);
103 :
104 0 : if (index !== -1) {
105 0 : notificationsArray.splice(index, 1);
106 0 : this.setState({ notifications: notificationsArray });
107 0 : }
108 0 : }
109 :
110 18 : updateUrl(options) {
111 18 : cockpit.location.go([], options);
112 18 : }
113 :
114 3 : onFilterChanged(value) {
115 3 : this.setState({
116 3 : textFilter: value
117 3 : });
118 :
119 3 : const options = { ...this.state.location };
120 3 : if (value === "")
121 3 : delete options.name;
122 : else
123 3 : options.name = value;
124 3 : this.updateUrl(options);
125 3 : }
126 :
127 1 : onOwnerChanged(value) {
128 1 : this.setState({
129 1 : ownerFilter: value
130 1 : });
131 :
132 1 : const options = { ...this.state.location };
133 1 : if (value == "all")
134 0 : delete options.owner;
135 : else
136 1 : options.owner = value.toString();
137 1 : this.updateUrl(options);
138 1 : }
139 :
140 17 : onContainerFilterChanged(value) {
141 17 : this.setState({
142 17 : containersFilter: value
143 17 : });
144 :
145 17 : const options = { ...this.state.location };
146 17 : if (value == "running")
147 4 : delete options.container;
148 : else
149 17 : options.container = value;
150 17 : this.updateUrl(options);
151 17 : }
152 :
153 40 : updateState(state, key, newValue) {
154 40 : this.setState(prevState => {
155 40 : return {
156 40 : [state]: { ...prevState[state], [key]: newValue }
157 40 : };
158 40 : });
159 40 : }
160 :
161 43 : updateContainerStats(con) {
162 43 : client.streamContainerStats(con, reply => {
163 43 : if (reply.Error != null) // executed when container stop
164 0 : console.warn("Failed to update container stats:", JSON.stringify(reply.message));
165 43 : else {
166 24 : reply.Stats.forEach(stat => this.updateState("containersStats", makeKey(con.uid, stat.ContainerID), stat));
167 43 : }
168 43 : }).catch(ex => {
169 0 : if (ex.cause == "no support for CGroups V1 in rootless environments" || ex.cause == "Container stats resource only available for cgroup v2") {
170 0 : console.log("This OS does not support CgroupsV2. Some information may be missing.");
171 0 : } else
172 0 : console.warn("Failed to update container stats:", JSON.stringify(ex.message));
173 0 : });
174 43 : }
175 :
176 43 : initContainers(con) {
177 43 : return client.getContainers(con)
178 43 : .then(containerList => Promise.all(
179 13 : containerList.map(container => client.inspectContainer(con, container.Id))
180 43 : ))
181 43 : .then(containerDetails => {
182 43 : this.setState(prevState => {
183 : // keep/copy the containers of other users
184 43 : const copyContainers = {};
185 3 : Object.entries(prevState.containers || {}).forEach(([id, container]) => {
186 3 : if (container.uid !== con.uid)
187 3 : copyContainers[id] = container;
188 3 : });
189 13 : for (const detail of containerDetails) {
190 13 : detail.uid = con.uid;
191 13 : detail.key = makeKey(con.uid, detail.Id);
192 13 : copyContainers[detail.key] = detail;
193 13 : }
194 :
195 27 : const users = prevState.users.map(u => u.uid === con.uid ? { ...u, containersLoaded: true } : u);
196 43 : return { containers: copyContainers, users };
197 43 : });
198 43 : this.updateContainerStats(con);
199 43 : })
200 0 : .catch(e => console.warn("initContainers uid", con.uid, "getContainers failed:", e.toString()));
201 43 : }
202 :
203 43 : updateImages(con) {
204 43 : client.getImages(con)
205 43 : .then(reply => {
206 43 : this.setState(prevState => {
207 : // Copy only images that could not be deleted with this event
208 : // So when event from one uid comes, only copy the other images
209 43 : const copyImages = {};
210 38 : Object.entries(prevState.images || {}).forEach(([Id, image]) => {
211 38 : if (image.uid !== con.uid)
212 26 : copyImages[Id] = image;
213 38 : });
214 43 : Object.entries(reply).forEach(([Id, image]) => {
215 43 : image.uid = con.uid;
216 43 : image.key = makeKey(con.uid, Id);
217 43 : copyImages[image.key] = image;
218 43 : });
219 :
220 27 : const users = prevState.users.map(u => u.uid === con.uid ? { ...u, imagesLoaded: true } : u);
221 43 : return { images: copyImages, users };
222 43 : });
223 43 : })
224 1 : .catch(ex => {
225 1 : console.warn("Failed to do updateImages for uid", con.uid, ":", JSON.stringify(ex));
226 1 : });
227 43 : }
228 :
229 43 : updatePods(con) {
230 43 : return client.getPods(con)
231 43 : .then(reply => {
232 43 : this.setState(prevState => {
233 : // Copy only pods that could not be deleted with this event
234 : // So when event from one uid comes, only copy the other pods
235 43 : const copyPods = {};
236 2 : Object.entries(prevState.pods || {}).forEach(([id, pod]) => {
237 2 : if (pod.uid !== con.uid)
238 1 : copyPods[id] = pod;
239 2 : });
240 0 : for (const pod of reply || []) {
241 5 : pod.uid = con.uid;
242 5 : pod.key = makeKey(con.uid, pod.Id);
243 5 : copyPods[pod.key] = pod;
244 5 : }
245 :
246 27 : const users = prevState.users.map(u => u.uid === con.uid ? { ...u, podsLoaded: true } : u);
247 43 : return { pods: copyPods, users };
248 43 : });
249 43 : })
250 0 : .catch(ex => {
251 0 : console.warn("Failed to do updatePods for uid", con.uid, ":", JSON.stringify(ex));
252 0 : });
253 43 : }
254 :
255 37 : updateContainer(con, id, event) {
256 : /* when firing off multiple calls in parallel, podman can return them in a random order.
257 : * This messes up the state. So we need to serialize them for a particular container. */
258 37 : const key = makeKey(con.uid, id);
259 37 : const wait = this.pendingUpdateContainer[key] ?? Promise.resolve();
260 :
261 37 : const new_wait = wait.then(() => client.inspectContainer(con, id))
262 37 : .then(details => {
263 37 : details.uid = con.uid;
264 37 : details.key = key;
265 : // HACK: during restart State never changes from "running"
266 : // override it to reconnect console after restart
267 37 : if (event?.Action === "restart")
268 0 : details.State.Status = "restarting";
269 37 : this.updateState("containers", key, details);
270 37 : })
271 6 : .catch(e => console.warn("updateContainer uid", con.uid, "inspectContainer failed:", e.toString()));
272 37 : this.pendingUpdateContainer[key] = new_wait;
273 37 : new_wait.finally(() => { delete this.pendingUpdateContainer[key] });
274 :
275 37 : return new_wait;
276 37 : }
277 :
278 4 : updateImage(con, id) {
279 4 : client.getImages(con, id)
280 4 : .then(reply => {
281 4 : const image = reply[id];
282 4 : image.uid = con.uid;
283 4 : image.key = makeKey(con.uid, id);
284 4 : this.updateState("images", image.key, image);
285 4 : })
286 2 : .catch(ex => {
287 2 : console.warn("Failed to do updateImage for uid", con.uid, ":", JSON.stringify(ex));
288 2 : });
289 4 : }
290 :
291 8 : updatePod(con, id) {
292 8 : return client.getPods(con, id)
293 8 : .then(reply => {
294 8 : if (reply && reply.length > 0) {
295 8 : const pod = reply[0];
296 :
297 8 : pod.uid = con.uid;
298 8 : pod.key = makeKey(con.uid, id);
299 8 : this.updateState("pods", pod.key, pod);
300 8 : }
301 8 : })
302 0 : .catch(ex => {
303 0 : console.warn("Failed to do updatePod for uid", con.uid, ":", JSON.stringify(ex));
304 0 : });
305 8 : }
306 :
307 : // see https://docs.podman.io/en/latest/markdown/podman-events.1.html
308 :
309 31 : handleImageEvent(event, con) {
310 31 : switch (event.Action) {
311 2 : case 'push':
312 2 : case 'save':
313 4 : case 'tag':
314 4 : this.updateImage(con, event.Actor.ID);
315 4 : break;
316 28 : case 'pull': // Pull event has not event.id
317 31 : case 'untag':
318 31 : case 'remove':
319 31 : case 'prune':
320 31 : case 'build':
321 31 : this.updateImages(con);
322 31 : break;
323 0 : default:
324 0 : console.warn('Unhandled event type ', event.Type, event.Action);
325 31 : }
326 31 : }
327 :
328 37 : handleContainerEvent(event, con) {
329 37 : const id = event.Actor.ID;
330 :
331 37 : switch (event.Action) {
332 : /* The following events do not need to trigger any state updates */
333 2 : case 'attach':
334 9 : case 'exec':
335 9 : case 'export':
336 9 : case 'import':
337 32 : case 'init':
338 32 : case 'kill':
339 32 : case 'mount':
340 32 : case 'prune':
341 32 : case 'restart':
342 32 : case 'sync':
343 32 : case 'unmount':
344 32 : case 'wait':
345 32 : break;
346 : /* The following events need only to update the Container list
347 : * We do get the container affected in the event object but for
348 : * now we 'll do a batch update
349 : */
350 32 : case 'start':
351 : // HACK: We don't get 'started' event for pods got started by the first container which was added to them
352 : // https://github.com/containers/podman/issues/7213
353 32 : (event.Actor.Attributes.podId
354 8 : ? this.updatePod(con, event.Actor.Attributes.podId)
355 28 : : this.updatePods(con)
356 32 : ).then(() => this.updateContainer(con, id, event));
357 32 : break;
358 1 : case 'checkpoint':
359 16 : case 'cleanup':
360 35 : case 'create':
361 35 : case 'died':
362 35 : case 'exec_died': // HACK: pick up health check runs with older podman versions, see https://github.com/containers/podman/issues/19237
363 35 : case 'health_status':
364 37 : case 'pause':
365 37 : case 'restore':
366 37 : case 'stop':
367 37 : case 'unpause':
368 37 : case 'rename': // rename event is available starting podman v4.1; until then the container does not get refreshed after renaming
369 37 : this.updateContainer(con, id, event);
370 37 : break;
371 :
372 8 : case 'remove':
373 8 : this.setState(prevState => {
374 8 : const containers = { ...prevState.containers };
375 8 : delete containers[makeKey(con.uid, id)];
376 8 : let pods;
377 :
378 1 : if (event.Actor.Attributes.podId) {
379 1 : const podKey = makeKey(con.uid, event.Actor.Attributes.podId);
380 1 : const newPod = { ...prevState.pods[podKey] };
381 1 : newPod.Containers = newPod.Containers.filter(container => container.Id !== id);
382 1 : pods = { ...prevState.pods, [podKey]: newPod };
383 0 : } else {
384 : // HACK: with podman < 4.3.0 we don't get a pod event when a container in a pod is removed
385 : // https://github.com/containers/podman/issues/15408
386 7 : pods = prevState.pods;
387 7 : this.updatePods(con);
388 7 : }
389 :
390 8 : return { containers, pods };
391 8 : });
392 8 : break;
393 :
394 : // only needs to update the Image list, this ought to be an image event
395 2 : case 'commit':
396 2 : this.updateImages(con);
397 2 : break;
398 0 : default:
399 0 : console.warn('Unhandled event type ', event.Type, event.Action);
400 37 : }
401 37 : }
402 :
403 8 : handlePodEvent(event, con) {
404 8 : switch (event.Action) {
405 8 : case 'create':
406 8 : case 'kill':
407 8 : case 'pause':
408 8 : case 'start':
409 8 : case 'stop':
410 8 : case 'unpause':
411 8 : this.updatePod(con, event.Actor.ID);
412 8 : break;
413 1 : case 'remove':
414 1 : this.setState(prevState => {
415 1 : const pods = { ...prevState.pods };
416 1 : delete pods[makeKey(con.uid, event.Actor.ID)];
417 1 : return { pods };
418 1 : });
419 1 : break;
420 0 : default:
421 0 : console.warn('Unhandled event type ', event.Type, event.Action);
422 8 : }
423 8 : }
424 :
425 40 : handleEvent(event, con) {
426 40 : switch (event.Type) {
427 37 : case 'container':
428 37 : this.handleContainerEvent(event, con);
429 37 : break;
430 31 : case 'image':
431 31 : this.handleImageEvent(event, con);
432 31 : break;
433 8 : case 'pod':
434 8 : this.handlePodEvent(event, con);
435 8 : break;
436 2 : default:
437 2 : console.warn('Unhandled event type ', event.Type);
438 40 : }
439 40 : }
440 :
441 2 : cleanupAfterService(con) {
442 2 : debug("cleanupAfterService", con.uid, "current owner filter:", this.state.ownerFilter);
443 2 : ["images", "containers", "pods"].forEach(t => {
444 2 : if (this.state[t])
445 2 : this.setState(prevState => {
446 2 : const copy = {};
447 0 : Object.entries(prevState[t] || {}).forEach(([id, v]) => {
448 2 : if (v.uid !== con.uid)
449 2 : copy[id] = v;
450 2 : });
451 2 : return { [t]: copy };
452 2 : });
453 2 : });
454 :
455 : // keep dummy (null) connections from other users, only remove valid ones
456 2 : this.setState(prevState => ({ users: prevState.users.filter(u => u.con === null || u.uid !== con.uid) }));
457 :
458 : // reset owner filter if the current filter is the closed connection
459 2 : if (con.uid == this.state.ownerFilter)
460 0 : this.onOwnerChanged("all");
461 2 : }
462 :
463 43 : async init(uid, username) {
464 43 : debug("init uid", uid, "name", username);
465 43 : const system = uid === 0;
466 41 : const is_other_user = (uid !== 0 && uid !== null);
467 :
468 43 : let con = null;
469 :
470 43 : try {
471 43 : const start_args = [
472 1 : ...(is_other_user ? ["runuser", "-u", username, "--"] : []),
473 43 : "systemctl",
474 41 : ...(system ? [] : ["--user"]),
475 43 : "start", "podman.socket"
476 43 : ];
477 1 : const environ = is_other_user ? ["XDG_RUNTIME_DIR=/run/user/" + uid] : [];
478 41 : await cockpit.spawn(start_args, { superuser: uid === null ? null : "require", err: "message", environ });
479 43 : con = rest.connect(uid);
480 43 : const reply = await client.getInfo(con);
481 43 : this.setState(prevState => {
482 43 : const users = prevState.users.filter(u => u.uid !== uid);
483 43 : users.push({ con, uid, name: username, containersLoaded: false, podsLoaded: false, imagesLoaded: false });
484 : // keep a nice sort order for dialogs
485 43 : users.sort(compareUser);
486 43 : debug("init uid", uid, "username", username, "new users:", users);
487 43 : return {
488 43 : users,
489 43 : version: reply.version.Version,
490 43 : registries: reply.registries,
491 43 : cgroupVersion: reply.host.cgroupVersion,
492 43 : };
493 43 : });
494 15 : } catch (err) {
495 15 : if (!system || err.problem != 'access-denied')
496 0 : console.warn("init uid", uid, "getInfo failed:", err.toString());
497 :
498 15 : this.setState(prevState => ({ users: prevState.users.filter(u => u.uid !== uid) }));
499 15 : return;
500 15 : }
501 :
502 43 : this.updateImages(con);
503 43 : this.initContainers(con);
504 43 : this.updatePods(con);
505 :
506 40 : client.streamEvents(con, message => this.handleEvent(message, con))
507 0 : .catch(e => console.error("uid", uid, "streamEvents failed:", e.toString()))
508 2 : .finally(() => {
509 2 : console.log("uid", uid, "podman service closed");
510 2 : this.cleanupAfterService(con);
511 2 : });
512 43 : }
513 :
514 43 : componentDidMount() {
515 43 : superuser.addEventListener("changed", () => this.init(0, _("system")));
516 :
517 43 : cockpit.user().then(user => {
518 : // there is no "user service" for root, ignore that
519 2 : if (user.id === 0) {
520 : // clear the dummy init user, otherwise UI waits forever for initialization
521 2 : this.setState(prevState => ({ users: prevState.users.filter(u => u.uid !== null) }));
522 2 : return;
523 2 : }
524 :
525 41 : cockpit.script("echo $XDG_RUNTIME_DIR")
526 41 : .then(xrd => {
527 41 : sessionStorage.setItem('XDG_RUNTIME_DIR', xrd.trim());
528 0 : this.init(null, user.name || _("User"));
529 41 : this.checkUserRestartService();
530 41 : })
531 0 : .catch(e => console.log("Could not read $XDG_RUNTIME_DIR:", e.message));
532 :
533 : // HACK: https://github.com/systemd/systemd/issues/22244#issuecomment-1210357701
534 41 : cockpit.file(`/var/lib/systemd/linger/${user.name}`).watch((content, tag) => {
535 40 : if (content == null && tag === '-') {
536 40 : this.setState({ userLingeringEnabled: false });
537 0 : } else {
538 1 : this.setState({ userLingeringEnabled: true });
539 1 : }
540 41 : });
541 :
542 : // detect which other users have containers running
543 41 : cockpit.spawn([
544 41 : 'find', '/sys/fs/cgroup',
545 : // RHEL 8 version still calls it "podman-*.scope", newer ones "libpod*"
546 41 : '(', '-name', 'libpod.*scope', '-o', '-name', 'podman-*.scope',
547 41 : '-o', '-name', 'libpod-payload*', ')',
548 41 : '-exec', 'stat', '--format=%u %U', '{}', ';'],
549 : // this find command doesn't need root, but user switching does;
550 : // hence skip the whole detection for unpriv sessions
551 41 : { superuser: "require", error: "message" })
552 27 : .then(output => {
553 27 : const other_users = [];
554 27 : const trimmed = output.trim();
555 27 : if (!trimmed)
556 27 : return;
557 :
558 27 : trimmed.split('\n').forEach(line => {
559 27 : const [uid_str, username] = line.split(' ');
560 27 : const uid = parseInt(uid_str);
561 0 : if (isNaN(uid)) {
562 0 : console.error(`User container detection: invalid uid '${uid_str}' in output '${output}'`); // not-covered: Should Not Happen™
563 0 : return; // not-covered: dito
564 0 : }
565 : // ignore standard users
566 27 : if (uid === 0 || uid === user.id)
567 27 : return;
568 1 : if (!other_users.find(u => u.uid === uid))
569 1 : other_users.push({ uid, name: username, con: null });
570 27 : });
571 27 : debug("other users who have containers running:", JSON.stringify(other_users));
572 27 : this.setState(prevState => ({ users: prevState.users.concat(other_users) }));
573 27 : })
574 14 : .catch(ex => {
575 14 : if (ex.problem == 'access-denied')
576 0 : debug("unprivileged session, skipping detection of other users");
577 : else
578 0 : console.warn("failed to detect other users:", ex);
579 14 : });
580 43 : });
581 :
582 43 : cockpit.spawn("selinuxenabled", { error: "ignore" })
583 43 : .then(() => this.setState({ selinuxAvailable: true }))
584 0 : .catch(() => this.setState({ selinuxAvailable: false }));
585 :
586 43 : cockpit.spawn(["systemctl", "show", "--value", "-p", "LoadState", "podman-restart"], { environ: ["LC_ALL=C"], error: "ignore" })
587 43 : .then(out => this.setState({ podmanRestartAvailable: out.trim() === "loaded" }));
588 :
589 43 : cockpit.addEventListener("locationchanged", this.onNavigate);
590 43 : this.onNavigate();
591 43 : }
592 :
593 0 : componentWillUnmount() {
594 0 : cockpit.removeEventListener("locationchanged", this.onNavigate);
595 0 : }
596 :
597 43 : onNavigate() {
598 : // HACK: Use usePageLocation when this is rewritten into a functional component
599 43 : const { options, path } = cockpit.location;
600 43 : this.setState({ location: options }, () => {
601 : // only use the root path
602 43 : if (path.length === 0) {
603 3 : if (options.name) {
604 3 : this.onFilterChanged(options.name);
605 3 : }
606 17 : if (options.container) {
607 17 : this.onContainerFilterChanged(options.container);
608 17 : }
609 43 : if (["all", undefined].includes(options.owner)) {
610 : // disconnect all non-standard users
611 43 : this.setState(prevState => ({
612 43 : users: prevState.users.map(u => {
613 0 : if (u.uid !== 0 && u.uid !== null && u.con) {
614 0 : debug("onNavigate All: closing unused connection to", u.name);
615 0 : u.con.close();
616 0 : return { uid: u.uid, name: u.name, con: null };
617 0 : } else
618 43 : return u;
619 43 : }),
620 43 : ownerFilter: "all",
621 43 : }));
622 1 : } else {
623 0 : const uid = options.owner === "user" ? null : parseInt(options.owner);
624 1 : const user = this.state.users.find(u => u.uid === uid);
625 1 : if (user) {
626 : // disconnect other non-standard users, to avoid piling up connections
627 1 : this.setState(prevState => ({
628 1 : users: prevState.users.map(u => {
629 0 : if (u.uid !== uid && u.uid !== 0 && u.uid !== null && u.con) {
630 0 : debug("onNavigate", user.name, ": closing unused connection to", u.name);
631 0 : u.con.close();
632 0 : return { uid: u.uid, name: u.name, con: null };
633 0 : } else
634 1 : return u;
635 1 : }),
636 0 : ownerFilter: uid === null ? "user" : uid,
637 1 : }), () => {
638 1 : if (user.con === null) {
639 1 : debug("onNavigate", user.name, ": initializing connection");
640 1 : this.init(user.uid, user.name);
641 0 : } else {
642 0 : debug("onNavigate", user.name, ": connection already initialized");
643 0 : }
644 1 : });
645 0 : } else {
646 0 : console.warn("Unknown user", options.owner, "in URL, ignoring");
647 0 : debug("known users:", JSON.stringify(this.state.users.map(u => [u.name, u.uid])));
648 : // reset URL to current value
649 0 : this.updateUrl({ ...this.state.location, owner: this.state.ownerFilter });
650 0 : }
651 1 : }
652 43 : }
653 43 : });
654 43 : }
655 :
656 41 : async checkUserRestartService() {
657 41 : const out = await cockpit.spawn(
658 41 : ["systemctl", "--user", "show", "--value", "-p", "LoadState", "podman-restart"],
659 41 : { environ: ["LC_ALL=C"], error: "ignore" });
660 41 : this.setState({ userPodmanRestartAvailable: out.trim() === "loaded" });
661 41 : }
662 :
663 0 : goToServicePage(e) {
664 0 : if (!e || e.button !== 0)
665 0 : return;
666 0 : cockpit.jump("/system/services#/podman.socket");
667 0 : }
668 :
669 43 : render() {
670 : // show troubleshoot if no users are available, i.e. all user's podman services failed
671 1 : if (this.state.users.length === 0) {
672 1 : return (
673 1 : <Page className="no-masthead-sidebar">
674 1 : <PageSection hasBodyWrapper={false}>
675 1 : <EmptyState headingLevel="h2" icon={ExclamationCircleIcon} titleText={_("Podman service failed")} variant={EmptyStateVariant.full}>
676 1 : <EmptyStateFooter>
677 1 : <EmptyStateActions>
678 1 : <Button variant="primary" onClick={this.goToServicePage}>
679 1 : {_("Troubleshoot")}
680 1 : </Button>
681 1 : </EmptyStateActions>
682 1 : </EmptyStateFooter>
683 1 : </EmptyState>
684 1 : </PageSection>
685 1 : </Page>
686 : );
687 1 : }
688 :
689 32 : if (this.state.users.find(u => u.con === null && (u.uid === 0 || u.uid === null))) // not initialized yet
690 43 : return null;
691 :
692 43 : let imageContainerList = {};
693 43 : if (this.state.containers !== null) {
694 41 : Object.keys(this.state.containers).forEach(c => {
695 41 : const container = this.state.containers[c];
696 41 : const imageKey = makeKey(container.uid, container.Image);
697 41 : if (!imageContainerList[imageKey])
698 41 : imageContainerList[imageKey] = [];
699 41 : imageContainerList[imageKey].push({
700 41 : container,
701 41 : stats: this.state.containersStats[makeKey(container.uid, container.Id)],
702 41 : });
703 41 : });
704 43 : } else
705 38 : imageContainerList = null;
706 :
707 43 : const loadingImages = this.state.users.find(u => u.con && !u.imagesLoaded);
708 43 : const loadingContainers = this.state.users.find(u => u.con && !u.containersLoaded);
709 43 : const loadingPods = this.state.users.find(u => u.con && !u.podsLoaded);
710 :
711 43 : const imageList = (
712 43 : <Images
713 43 : key="imageList"
714 43 : images={loadingImages ? null : this.state.images}
715 43 : imageContainerList={imageContainerList}
716 43 : onAddNotification={this.onAddNotification}
717 43 : textFilter={this.state.textFilter}
718 43 : ownerFilter={this.state.ownerFilter}
719 2 : showAll={ () => this.setState({ containersFilter: "all" }) }
720 43 : users={this.state.users}
721 43 : />
722 : );
723 43 : const containerList = (
724 43 : <Containers
725 43 : key="containerList"
726 43 : version={this.state.version}
727 43 : images={loadingImages ? null : this.state.images}
728 43 : containers={loadingContainers ? null : this.state.containers}
729 43 : pods={loadingPods ? null : this.state.pods}
730 43 : containersStats={this.state.containersStats}
731 43 : filter={this.state.containersFilter}
732 43 : handleFilterChange={this.onContainerFilterChanged}
733 43 : textFilter={this.state.textFilter}
734 43 : ownerFilter={this.state.ownerFilter}
735 43 : users={this.state.users}
736 43 : onAddNotification={this.onAddNotification}
737 43 : cgroupVersion={this.state.cgroupVersion}
738 43 : updateContainer={this.updateContainer}
739 43 : />
740 : );
741 :
742 43 : const notificationList = (
743 43 : <AlertGroup isToast>
744 1 : {this.state.notifications.map((notification, index) => {
745 1 : return (
746 1 : <Alert key={index} title={notification.error} variant={notification.type}
747 1 : isLiveRegion
748 0 : actionClose={<AlertActionCloseButton onClose={() => this.onDismissNotification(notification.index)} />}>
749 1 : {notification.errorDetail}
750 1 : </Alert>
751 : );
752 1 : })}
753 43 : </AlertGroup>
754 : );
755 :
756 43 : const contextInfo = {
757 43 : cgroupVersion: this.state.cgroupVersion,
758 43 : registries: this.state.registries,
759 43 : selinuxAvailable: this.state.selinuxAvailable,
760 43 : podmanRestartAvailable: this.state.podmanRestartAvailable,
761 43 : userPodmanRestartAvailable: this.state.userPodmanRestartAvailable,
762 43 : userLingeringEnabled: this.state.userLingeringEnabled,
763 43 : version: this.state.version,
764 43 : };
765 :
766 43 : return (
767 43 : <WithPodmanInfo value={contextInfo}>
768 43 : <WithDialogs>
769 43 : <Page id="overview" key="overview" className="no-masthead-sidebar">
770 43 : {notificationList}
771 43 : <PageSection hasBodyWrapper={false} className="content-filter"
772 : >
773 43 : <ContainerHeader
774 43 : handleFilterChanged={this.onFilterChanged}
775 43 : handleOwnerChanged={this.onOwnerChanged}
776 43 : ownerFilter={this.state.ownerFilter}
777 43 : textFilter={this.state.textFilter}
778 43 : users={this.state.users}
779 43 : />
780 43 : </PageSection>
781 43 : <PageSection hasBodyWrapper={false} className='ct-pagesection-mobile'>
782 43 : <Stack hasGutter>
783 43 : {imageList}
784 43 : {containerList}
785 43 : </Stack>
786 43 : </PageSection>
787 43 : </Page>
788 43 : </WithDialogs>
789 43 : </WithPodmanInfo>
790 : );
791 43 : }
792 43 : }
793 :
794 43 : export default Application;
|