/*
* This file is part of Cockpit.
*
* Copyright (C) 2017 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see .
*/
import React from 'react';
import { Page, PageSection, PageSectionVariants } from "@patternfly/react-core/dist/esm/components/Page";
import { Alert, AlertActionCloseButton, AlertActionLink, AlertGroup } from "@patternfly/react-core/dist/esm/components/Alert";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
import { EmptyState, EmptyStateHeader, EmptyStateFooter, EmptyStateIcon, EmptyStateActions, EmptyStateVariant } from "@patternfly/react-core/dist/esm/components/EmptyState";
import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack";
import { ExclamationCircleIcon } from '@patternfly/react-icons';
import { WithDialogs } from "dialogs.jsx";
import cockpit from 'cockpit';
import { superuser } from "superuser";
import ContainerHeader from './ContainerHeader.jsx';
import Containers from './Containers.jsx';
import Images from './Images.jsx';
import * as client from './client.js';
import { WithPodmanInfo } from './util.js';
const _ = cockpit.gettext;
class Application extends React.Component {
constructor(props) {
super(props);
this.state = {
systemServiceAvailable: null,
userServiceAvailable: null,
enableService: true,
images: null,
userImagesLoaded: false,
systemImagesLoaded: false,
containers: null,
containersFilter: "all",
containersStats: {},
userContainersLoaded: null,
systemContainersLoaded: null,
userPodsLoaded: null,
systemPodsLoaded: null,
userServiceExists: false,
textFilter: "",
ownerFilter: "all",
dropDownValue: 'Everything',
notifications: [],
showStartService: true,
version: '1.3.0',
selinuxAvailable: false,
podmanRestartAvailable: false,
userPodmanRestartAvailable: false,
currentUser: _("User"),
userLingeringEnabled: null,
privileged: false,
location: {},
};
this.onAddNotification = this.onAddNotification.bind(this);
this.onDismissNotification = this.onDismissNotification.bind(this);
this.onFilterChanged = this.onFilterChanged.bind(this);
this.onOwnerChanged = this.onOwnerChanged.bind(this);
this.onContainerFilterChanged = this.onContainerFilterChanged.bind(this);
this.updateContainer = this.updateContainer.bind(this);
this.startService = this.startService.bind(this);
this.goToServicePage = this.goToServicePage.bind(this);
this.checkUserService = this.checkUserService.bind(this);
this.onNavigate = this.onNavigate.bind(this);
this.pendingUpdateContainer = {}; // id+system → promise
}
onAddNotification(notification) {
notification.index = this.state.notifications.length;
this.setState(prevState => ({
notifications: [
...prevState.notifications,
notification
]
}));
}
onDismissNotification(notificationIndex) {
const notificationsArray = this.state.notifications.concat();
const index = notificationsArray.findIndex(current => current.index == notificationIndex);
if (index !== -1) {
notificationsArray.splice(index, 1);
this.setState({ notifications: notificationsArray });
}
}
updateUrl(options) {
cockpit.location.go([], options);
}
onFilterChanged(value) {
this.setState({
textFilter: value
});
const options = this.state.location;
if (value === "") {
delete options.name;
this.updateUrl(Object.assign(options));
} else {
this.updateUrl(Object.assign(this.state.location, { name: value }));
}
}
onOwnerChanged(value) {
this.setState({
ownerFilter: value
});
const options = this.state.location;
if (value == "all") {
delete options.owner;
this.updateUrl(Object.assign(options));
} else {
this.updateUrl(Object.assign(options, { owner: value }));
}
}
onContainerFilterChanged(value) {
this.setState({
containersFilter: value
});
const options = this.state.location;
if (value == "running") {
delete options.container;
this.updateUrl(Object.assign(options));
} else {
this.updateUrl(Object.assign(options, { container: value }));
}
}
updateState(state, id, newValue) {
this.setState(prevState => {
return {
[state]: { ...prevState[state], [id]: newValue }
};
});
}
updateContainerStats(system) {
client.streamContainerStats(system, reply => {
if (reply.Error != null) // executed when container stop
console.warn("Failed to update container stats:", JSON.stringify(reply.message));
else {
reply.Stats.forEach(stat => this.updateState("containersStats", stat.ContainerID + system.toString(), stat));
}
}).catch(ex => {
if (ex.cause == "no support for CGroups V1 in rootless environments" || ex.cause == "Container stats resource only available for cgroup v2") {
console.log("This OS does not support CgroupsV2. Some information may be missing.");
} else
console.warn("Failed to update container stats:", JSON.stringify(ex.message));
});
}
initContainers(system) {
return client.getContainers(system)
.then(containerList => Promise.all(
containerList.map(container => client.inspectContainer(system, container.Id))
))
.then(containerDetails => {
this.setState(prevState => {
// keep/copy the containers for !system
const copyContainers = {};
Object.entries(prevState.containers || {}).forEach(([id, container]) => {
if (container.isSystem !== system)
copyContainers[id] = container;
});
for (const detail of containerDetails) {
detail.isSystem = system;
copyContainers[detail.Id + system.toString()] = detail;
}
return {
containers: copyContainers,
[system ? "systemContainersLoaded" : "userContainersLoaded"]: true,
};
});
this.updateContainerStats(system);
})
.catch(console.log);
}
updateImages(system) {
client.getImages(system)
.then(reply => {
this.setState(prevState => {
// Copy only images that could not be deleted with this event
// So when event from system come, only copy user images and vice versa
const copyImages = {};
Object.entries(prevState.images || {}).forEach(([Id, image]) => {
if (image.isSystem !== system)
copyImages[Id] = image;
});
Object.entries(reply).forEach(([Id, image]) => {
image.isSystem = system;
copyImages[Id + system.toString()] = image;
});
return {
images: copyImages,
[system ? "systemImagesLoaded" : "userImagesLoaded"]: true
};
});
})
.catch(ex => {
console.warn("Failed to do Update Images:", JSON.stringify(ex));
});
}
updatePods(system) {
return client.getPods(system)
.then(reply => {
this.setState(prevState => {
// Copy only pods that could not be deleted with this event
// So when event from system come, only copy user pods and vice versa
const copyPods = {};
Object.entries(prevState.pods || {}).forEach(([id, pod]) => {
if (pod.isSystem !== system)
copyPods[id] = pod;
});
for (const pod of reply || []) {
pod.isSystem = system;
copyPods[pod.Id + system.toString()] = pod;
}
return {
pods: copyPods,
[system ? "systemPodsLoaded" : "userPodsLoaded"]: true,
};
});
})
.catch(ex => {
console.warn("Failed to do Update Pods:", JSON.stringify(ex));
});
}
updateContainer(id, system, event) {
/* when firing off multiple calls in parallel, podman can return them in a random order.
* This messes up the state. So we need to serialize them for a particular container. */
const idx = id + system.toString();
const wait = this.pendingUpdateContainer[idx] ?? Promise.resolve();
const new_wait = wait.then(() => client.inspectContainer(system, id))
.then(details => {
details.isSystem = system;
// HACK: during restart State never changes from "running"
// override it to reconnect console after restart
if (event?.Action === "restart")
details.State.Status = "restarting";
this.updateState("containers", idx, details);
})
.catch(console.log);
this.pendingUpdateContainer[idx] = new_wait;
new_wait.finally(() => { delete this.pendingUpdateContainer[idx] });
return new_wait;
}
updateImage(id, system) {
client.getImages(system, id)
.then(reply => {
const immage = reply[id];
immage.isSystem = system;
this.updateState("images", id + system.toString(), immage);
})
.catch(ex => {
console.warn("Failed to do Update Image:", JSON.stringify(ex));
});
}
updatePod(id, system) {
return client.getPods(system, id)
.then(reply => {
if (reply && reply.length > 0) {
reply = reply[0];
reply.isSystem = system;
this.updateState("pods", reply.Id + system.toString(), reply);
}
})
.catch(ex => {
console.warn("Failed to do Update Pod:", JSON.stringify(ex));
});
}
// see https://docs.podman.io/en/latest/markdown/podman-events.1.html
handleImageEvent(event, system) {
switch (event.Action) {
case 'push':
case 'save':
case 'tag':
this.updateImage(event.Actor.ID, system);
break;
case 'pull': // Pull event has not event.id
case 'untag':
case 'remove':
case 'prune':
case 'build':
this.updateImages(system);
break;
default:
console.warn('Unhandled event type ', event.Type, event.Action);
}
}
handleContainerEvent(event, system) {
const id = event.Actor.ID;
switch (event.Action) {
/* The following events do not need to trigger any state updates */
case 'attach':
case 'exec':
case 'export':
case 'import':
case 'init':
case 'kill':
case 'mount':
case 'prune':
case 'restart':
case 'sync':
case 'unmount':
case 'wait':
break;
/* The following events need only to update the Container list
* We do get the container affected in the event object but for
* now we 'll do a batch update
*/
case 'start':
// HACK: We don't get 'started' event for pods got started by the first container which was added to them
// https://github.com/containers/podman/issues/7213
(event.Actor.Attributes.podId
? this.updatePod(event.Actor.Attributes.podId, system)
: this.updatePods(system)
).then(() => this.updateContainer(id, system, event));
break;
case 'checkpoint':
case 'cleanup':
case 'create':
case 'died':
case 'exec_died': // HACK: pick up health check runs with older podman versions, see https://github.com/containers/podman/issues/19237
case 'health_status':
case 'pause':
case 'restore':
case 'stop':
case 'unpause':
case 'rename': // rename event is available starting podman v4.1; until then the container does not get refreshed after renaming
this.updateContainer(id, system, event);
break;
case 'remove':
this.setState(prevState => {
const containers = { ...prevState.containers };
delete containers[id + system.toString()];
let pods;
if (event.Actor.Attributes.podId) {
const podIdx = event.Actor.Attributes.podId + system.toString();
const newPod = { ...prevState.pods[podIdx] };
newPod.Containers = newPod.Containers.filter(container => container.Id !== id);
pods = { ...prevState.pods, [podIdx]: newPod };
} else {
// HACK: with podman < 4.3.0 we don't get a pod event when a container in a pod is removed
// https://github.com/containers/podman/issues/15408
pods = prevState.pods;
this.updatePods(system);
}
return { containers, pods };
});
break;
// only needs to update the Image list, this ought to be an image event
case 'commit':
this.updateImages(system);
break;
default:
console.warn('Unhandled event type ', event.Type, event.Action);
}
}
handlePodEvent(event, system) {
switch (event.Action) {
case 'create':
case 'kill':
case 'pause':
case 'start':
case 'stop':
case 'unpause':
this.updatePod(event.Actor.ID, system);
break;
case 'remove':
this.setState(prevState => {
const pods = { ...prevState.pods };
delete pods[event.Actor.ID + system.toString()];
return { pods };
});
break;
default:
console.warn('Unhandled event type ', event.Type, event.Action);
}
}
handleEvent(event, system) {
switch (event.Type) {
case 'container':
this.handleContainerEvent(event, system);
break;
case 'image':
this.handleImageEvent(event, system);
break;
case 'pod':
this.handlePodEvent(event, system);
break;
default:
console.warn('Unhandled event type ', event.Type);
}
}
cleanupAfterService(system, key) {
["images", "containers", "pods"].forEach(t => {
if (this.state[t])
this.setState(prevState => {
const copy = {};
Object.entries(prevState[t] || {}).forEach(([id, v]) => {
if (v.isSystem !== system)
copy[id] = v;
});
return { [t]: copy };
});
});
}
init(system) {
client.getInfo(system)
.then(reply => {
this.setState({
[system ? "systemServiceAvailable" : "userServiceAvailable"]: true,
version: reply.version.Version,
registries: reply.registries,
cgroupVersion: reply.host.cgroupVersion,
});
this.updateImages(system);
this.initContainers(system);
this.updatePods(system);
client.streamEvents(system,
message => this.handleEvent(message, system))
.then(() => {
this.setState({ [system ? "systemServiceAvailable" : "userServiceAvailable"]: false });
this.cleanupAfterService(system);
})
.catch(e => {
console.log(e);
this.setState({ [system ? "systemServiceAvailable" : "userServiceAvailable"]: false });
this.cleanupAfterService(system);
});
// Listen if podman is still running
const ch = cockpit.channel({ superuser: system ? "require" : null, payload: "stream", unix: client.getAddress(system) });
ch.addEventListener("close", () => {
this.setState({ [system ? "systemServiceAvailable" : "userServiceAvailable"]: false });
this.cleanupAfterService(system);
});
ch.send("GET " + client.VERSION + "libpod/events HTTP/1.0\r\nContent-Length: 0\r\n\r\n");
})
.catch(() => {
this.setState({
[system ? "systemServiceAvailable" : "userServiceAvailable"]: false,
[system ? "systemContainersLoaded" : "userContainersLoaded"]: true,
[system ? "systemImagesLoaded" : "userImagesLoaded"]: true,
[system ? "systemPodsLoaded" : "userPodsLoaded"]: true
});
});
}
componentDidMount() {
this.init(true);
cockpit.script("[ `id -u` -eq 0 ] || echo $XDG_RUNTIME_DIR")
.done(xrd => {
const isRoot = !xrd || xrd.split("/").pop() == "root";
if (!isRoot) {
sessionStorage.setItem('XDG_RUNTIME_DIR', xrd.trim());
this.init(false);
this.checkUserService();
} else {
this.setState({
userImagesLoaded: true,
userContainersLoaded: true,
userPodsLoaded: true,
userServiceExists: false
});
}
})
.fail(e => console.log("Could not read $XDG_RUNTIME_DIR: ", e.message));
cockpit.spawn("selinuxenabled", { error: "ignore" })
.then(() => this.setState({ selinuxAvailable: true }))
.catch(() => this.setState({ selinuxAvailable: false }));
cockpit.spawn(["systemctl", "show", "--value", "-p", "LoadState", "podman-restart"], { environ: ["LC_ALL=C"], error: "ignore" })
.then(out => this.setState({ podmanRestartAvailable: out.trim() === "loaded" }));
superuser.addEventListener("changed", () => this.setState({ privileged: !!superuser.allowed }));
this.setState({ privileged: superuser.allowed });
cockpit.user().then(user => {
this.setState({ currentUser: user.name || _("User") });
// HACK: https://github.com/systemd/systemd/issues/22244#issuecomment-1210357701
cockpit.file(`/var/lib/systemd/linger/${user.name}`).watch((content, tag) => {
if (content == null && tag === '-') {
this.setState({ userLingeringEnabled: false });
} else {
this.setState({ userLingeringEnabled: true });
}
});
});
cockpit.addEventListener("locationchanged", this.onNavigate);
this.onNavigate();
}
componentWillUnmount() {
cockpit.removeEventListener("locationchanged", this.onNavigate);
}
onNavigate() {
// HACK: Use usePageLocation when this is rewritten into a functional component
const { options, path } = cockpit.location;
this.setState({ location: options }, () => {
// only use the root path
if (path.length === 0) {
if (options.name) {
this.onFilterChanged(options.name);
}
if (options.container) {
this.onContainerFilterChanged(options.container);
}
const owners = ["user", "system", "all"];
if (owners.indexOf(options.owner) !== -1) {
this.onOwnerChanged(options.owner);
}
}
});
}
checkUserService() {
const argv = ["systemctl", "--user", "is-enabled", "podman.socket"];
cockpit.spawn(["systemctl", "--user", "show", "--value", "-p", "LoadState", "podman-restart"], { environ: ["LC_ALL=C"], error: "ignore" })
.then(out => this.setState({ userPodmanRestartAvailable: out.trim() === "loaded" }));
cockpit.spawn(argv, { environ: ["LC_ALL=C"], err: "out" })
.then(() => this.setState({ userServiceExists: true }))
.catch((_, response) => {
if (response.trim() !== "disabled")
this.setState({ userServiceExists: false });
else
this.setState({ userServiceExists: true });
});
}
startService(e) {
if (!e || e.button !== 0)
return;
let argv;
if (this.state.enableService)
argv = ["systemctl", "enable", "--now", "podman.socket"];
else
argv = ["systemctl", "start", "podman.socket"];
cockpit.spawn(argv, { superuser: "require", err: "message" })
.then(() => this.init(true))
.catch(err => {
this.setState({
systemServiceAvailable: false,
systemContainersLoaded: true,
systemImagesLoaded: true
});
console.warn("Failed to start system podman.socket:", JSON.stringify(err));
});
if (this.state.enableService)
argv = ["systemctl", "--user", "enable", "--now", "podman.socket"];
else
argv = ["systemctl", "--user", "start", "podman.socket"];
cockpit.spawn(argv, { err: "message" })
.then(() => this.init(false))
.catch(err => {
this.setState({
userServiceAvailable: false,
userContainersLoaded: true,
userPodsLoaded: true,
userImagesLoaded: true
});
console.warn("Failed to start user podman.socket:", JSON.stringify(err));
});
}
goToServicePage(e) {
if (!e || e.button !== 0)
return;
cockpit.jump("/system/services#/podman.socket");
}
render() {
if (this.state.systemServiceAvailable === null && this.state.userServiceAvailable === null) // not detected yet
return null;
if (!this.state.systemServiceAvailable && !this.state.userServiceAvailable) {
return (
} headingLevel="h2" />
this.setState({ enableService: checked }) } />
{ cockpit.manifests.system &&
}
);
}
let imageContainerList = {};
if (this.state.containers !== null) {
Object.keys(this.state.containers).forEach(c => {
const container = this.state.containers[c];
const image = container.Image + container.isSystem.toString();
if (imageContainerList[image]) {
imageContainerList[image].push({
container,
stats: this.state.containersStats[container.Id + container.isSystem.toString()],
});
} else {
imageContainerList[image] = [{
container,
stats: this.state.containersStats[container.Id + container.isSystem.toString()]
}];
}
});
} else
imageContainerList = null;
let startService = "";
const action = (
<>
{_("Start")}
this.setState({ showStartService: false })} />
>
);
if (!this.state.systemServiceAvailable && this.state.privileged) {
startService = (
);
}
if (!this.state.userServiceAvailable && this.state.userServiceExists) {
startService = (
);
}
const imageList = (
this.setState({ containersFilter: "all" }) }
user={this.state.currentUser}
userServiceAvailable={this.state.userServiceAvailable}
systemServiceAvailable={this.state.systemServiceAvailable}
/>
);
const containerList = (
);
const notificationList = (
{this.state.notifications.map((notification, index) => {
return (
this.onDismissNotification(notification.index)} />}>
{notification.errorDetail}
);
})}
);
const contextInfo = {
cgroupVersion: this.state.cgroupVersion,
registries: this.state.registries,
selinuxAvailable: this.state.selinuxAvailable,
podmanRestartAvailable: this.state.podmanRestartAvailable,
userPodmanRestartAvailable: this.state.userPodmanRestartAvailable,
userLingeringEnabled: this.state.userLingeringEnabled,
version: this.state.version,
};
return (
{notificationList}
{ this.state.showStartService ? startService : null }
{imageList}
{containerList}
);
}
}
export default Application;