From 278eb633f0ebf4bfae29c53b0ddab306016b1ac0 Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Fri, 15 Jul 2022 11:38:50 +0200 Subject: [PATCH 1/3] Show pod details A pod consists out of one or more containers which consume CPU, memory which we would like to show combined in the pod's header. The CPU usage is reported per container, so we do not add them all up but show the highest CPU usage of one of the containers in the pod. For memory usage we do add up all containers memory usage. Pods can also have a port and volume mapping which is shared with every container, the port/volume mapping can be obtained from the infrastructure container which delegates these mappings. --- src/ContainerIntegration.jsx | 4 +- src/Containers.jsx | 119 ++++++++++++++++++++++++++++++++--- src/Containers.scss | 52 ++++++++++++++- src/podman.scss | 2 +- test/check-application | 34 +++++++++- 5 files changed, 196 insertions(+), 15 deletions(-) diff --git a/src/ContainerIntegration.jsx b/src/ContainerIntegration.jsx index ec52e1193..9ba6fc1b1 100644 --- a/src/ContainerIntegration.jsx +++ b/src/ContainerIntegration.jsx @@ -7,7 +7,7 @@ import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; const _ = cockpit.gettext; -const renderContainerPublishedPorts = (ports) => { +export const renderContainerPublishedPorts = (ports) => { if (!ports) return null; @@ -28,7 +28,7 @@ const renderContainerPublishedPorts = (ports) => { return {result}; }; -const renderContainerVolumes = (volumes) => { +export const renderContainerVolumes = (volumes) => { if (!volumes.length) return null; diff --git a/src/Containers.jsx b/src/Containers.jsx index 649df5703..ff197dfc9 100644 --- a/src/Containers.jsx +++ b/src/Containers.jsx @@ -7,17 +7,19 @@ import { Dropdown, DropdownItem, DropdownSeparator, Flex, KebabToggle, + Popover, LabelGroup, Text, TextVariants, FormSelect, FormSelectOption, - Toolbar, ToolbarContent, ToolbarItem, + Tooltip, Toolbar, ToolbarContent, ToolbarItem, } from '@patternfly/react-core'; import { cellWidth } from '@patternfly/react-table'; +import { MicrochipIcon, MemoryIcon, PortIcon, VolumeIcon, } from '@patternfly/react-icons'; import cockpit from 'cockpit'; import { ListingTable } from "cockpit-components-table.jsx"; import { ListingPanel } from 'cockpit-components-listing-panel.jsx'; import ContainerDetails from './ContainerDetails.jsx'; -import ContainerIntegration from './ContainerIntegration.jsx'; +import ContainerIntegration, { renderContainerPublishedPorts, renderContainerVolumes } from './ContainerIntegration.jsx'; import ContainerTerminal from './ContainerTerminal.jsx'; import ContainerLogs from './ContainerLogs.jsx'; import ContainerHealthLogs from './ContainerHealthLogs.jsx'; @@ -32,6 +34,7 @@ import ContainerRenameModal from './ContainerRenameModal.jsx'; import { useDialogs, DialogsContext } from "dialogs.jsx"; import './Containers.scss'; +import '@patternfly/patternfly/utilities/Accessibility/accessibility.css'; import { ImageRunModal } from './ImageRunModal.jsx'; import { PodActions } from './PodActions.jsx'; @@ -326,6 +329,7 @@ class Containers extends React.Component { }; this.renderRow = this.renderRow.bind(this); this.onWindowResize = this.onWindowResize.bind(this); + this.podStats = this.podStats.bind(this); this.cardRef = React.createRef(); @@ -442,6 +446,101 @@ class Containers extends React.Component { this.setState({ width: this.cardRef.current.clientWidth }); } + podStats(pod) { + const { containersStats } = this.props; + // when no containers exists pod.Containers is null + if (!containersStats || !pod.Containers) { + return null; + } + + // As podman does not provide per pod memory/cpu statistics we do the following: + // - don't add up CPU usage, instead display the highest found CPU usage of the containers in a pod + // - add up memory usage so it displays the total memory of the pod. + let cpu = 0; + let mem = 0; + for (const container of pod.Containers) { + const containerStats = containersStats[container.Id + pod.isSystem.toString()]; + if (!containerStats) + continue; + + if (containerStats.CPU != undefined) { + const val = containerStats.CPU === 0 ? containerStats.CPU : containerStats.CPU.toFixed(2); + if (val > cpu) + cpu = val; + } + if (containerStats.MemUsage != undefined) + mem += containerStats.MemUsage; + } + + return { + cpu, + mem, + }; + } + + renderPodDetails(pod, podStatus) { + const podStats = this.podStats(pod); + const infraContainer = this.props.containers[pod.InfraId + pod.isSystem.toString()]; + const infraContainerDetails = this.props.containersDetails[pod.InfraId + pod.isSystem.toString()]; + + return ( + <> + {podStats && podStatus === "Running" && + <> + + + + + {_("CPU")} + {podStats.cpu}% + + + + + + {_("Memory")} + {utils.format_memory_and_limit(podStats.mem) || "0 KB"} + + + } + {infraContainer && infraContainerDetails && + <> + {infraContainer.Ports && infraContainer.Ports.length !== 0 && + + + + + + } + {infraContainerDetails.Mounts && infraContainerDetails.Mounts.length !== 0 && + + + + + + } + + } + + ); + } + render() { const Dialogs = this.context; const columnTitles = [ @@ -627,9 +726,10 @@ class Containers extends React.Component { let caption; let podStatus; if (section !== 'no-pod') { - tableProps['aria-label'] = cockpit.format("Containers of Pod $0", this.props.pods[section].Name); - podStatus = this.props.pods[section].Status; - caption = this.props.pods[section].Name; + const pod = this.props.pods[section]; + tableProps['aria-label'] = cockpit.format("Containers of pod $0", pod.Name); + podStatus = pod.Status; + caption = pod.Name; } else { tableProps['aria-label'] = _("Containers"); } @@ -638,11 +738,14 @@ class Containers extends React.Component { id={'table-' + (section == "no-pod" ? section : this.props.pods[section].Name)} isPlain={section == "no-pod"} isFlat={section != "no-pod"} - className="container-section"> + className="container-pod"> {caption && - {caption} - {_("pod group")} + + {caption} + {_("pod group")} + {this.renderPodDetails(this.props.pods[section], podStatus)} + {_(podStatus)} diff --git a/src/Containers.scss b/src/Containers.scss index eba66c687..5795771cf 100644 --- a/src/Containers.scss +++ b/src/Containers.scss @@ -1,9 +1,28 @@ -.container-section { +@import "global-variables"; + +.container-pod { .pf-c-card__header { border-color: #ddd; padding-top: var(--pf-global--spacer--md); } + .pod-header-details { + border-color: #ddd; + margin-top: var(--pf-global--spacer--md); + margin-left: var(--pf-global--spacer--md); + margin-right: var(--pf-global--spacer--md); + } + + .pod-details-button { + padding-left: 0; + padding-right: 0; + margin-right: var(--pf-global--spacer--md); + } + + .pod-details-button-color { + color: var(--pf-c-button--m-secondary--Color); + } + .pf-c-card__title { padding: 0; font-weight: var(--pf-global--FontWeight--normal); @@ -16,8 +35,35 @@ } } - .pf-c-card__header:not(:last-child) { - padding-bottom: var(--pf-global--spacer-sm); + > .pf-c-card__header { + &:not(:last-child) { + padding-bottom: var(--pf-global--spacer-sm); + } + + // Reduce vertical padding of pod header items + > .pf-c-card__title > .pf-l-flex { + row-gap: var(--pf-global--spacer--sm); + } + } +} + +.pod-stat { + @media (max-width: $pf-global--breakpoint--sm - 1) { + // Place each pod stat on its own row + flex-basis: 100%; + display: grid; + // Give labels to the same space + grid-template-columns: minmax(auto, 4rem) 1fr; + + > svg { + // Hide icons in mobile to be consistent with container lists + display: none; + } + } + + // Center the icons for proper vertical alignment + > svg { + align-self: center; } } diff --git a/src/podman.scss b/src/podman.scss index ad3a428ca..9f929ffb9 100644 --- a/src/podman.scss +++ b/src/podman.scss @@ -110,7 +110,7 @@ } // Add borders to no pod containers list and images list - .container-section.pf-m-plain tbody, + .container-pod.pf-m-plain tbody, .containers-images tbody { border: var(--pf-c-card--m-flat--BorderWidth) solid var(--pf-c-card--m-flat--BorderColor); } diff --git a/test/check-application b/test/check-application index 1d226d888..28d435651 100755 --- a/test/check-application +++ b/test/check-application @@ -214,9 +214,27 @@ class TestApplication(testlib.MachineCase): self.filter_containers("all") self.waitPodContainer("pod-1", []) + def get_pod_cpu_usage(pod_name): + cpu = self.browser.text(f"#table-{pod_name}-title .pod-cpu") + self.assertIn('%', cpu) + return float(cpu[:-1]) + + def get_pod_memory(pod_name): + memory = self.browser.text(f"#table-{pod_name}-title .pod-memory") + memory, unit = memory.split(' ') + self.assertIn(unit, ["GB", "MB", "KB"]) + return float(memory) + containerId = self.machine.execute("podman run -d --pod pod-1 --name test-pod-1-system alpine sleep 100").strip() self.waitPodContainer("pod-1", [{"name": "test-pod-1-system", "image": "alpine", "command": "sleep 100", "state": "Running", "id": containerId}]) - self.machine.execute("podman pod stop pod-1") + cpu = get_pod_cpu_usage("pod-1") + b.wait(lambda: get_pod_memory("pod-1") > 0) + + # Test that cpu usage increases + self.machine.execute("podman exec -i test-pod-1-system sh -c 'dd bs=1024 count=1000000 < /dev/urandom > /dev/null'") + b.wait(lambda: get_pod_cpu_usage("pod-1") > cpu) + + self.machine.execute("podman pod stop -t0 pod-1") # disable timeout, so test doesn't wait endlessly self.waitPodContainer("pod-1", [{"name": "test-pod-1-system", "image": "alpine", "command": "sleep 100", "state": NOT_RUNNING, "id": containerId}]) self.filter_containers("running") self.waitPodRow("pod-1", False) @@ -267,6 +285,20 @@ class TestApplication(testlib.MachineCase): b.click(".pf-c-modal-box button:contains('Delete')") self.waitPodRow("pod-2", False) + # Volumes / mounts + self.machine.execute("podman pod create -p 9999:9999 -v /tmp:/app --name pod-3") + self.machine.execute("podman pod start pod-3") + + self.waitPodContainer("pod-3", []) + # Verify 1 port mapping + b.wait_in_text("#table-pod-3-title .pod-details-ports-btn", "1") + b.click("#table-pod-3-title .pod-details-ports-btn") + b.wait_in_text(".pf-c-popover__content", "0.0.0.0:9999 → 9999/tcp") + # Verify 1 mount + b.wait_in_text("#table-pod-3-title .pod-details-volumes-btn", "1") + b.click("#table-pod-3-title .pod-details-volumes-btn") + b.wait_in_text(".pf-c-popover__content", "/tmp ↔ /app") + @testlib.nondestructive def testBasicSystem(self): self._testBasic(True) From a7f5d29c593eb02b23231c0c3691cdbcda5019fa Mon Sep 17 00:00:00 2001 From: Garrett LeSage Date: Tue, 13 Sep 2022 17:19:15 +0200 Subject: [PATCH 2/3] pod: Promote pod name to heading, h3 level --- src/Containers.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Containers.jsx b/src/Containers.jsx index ff197dfc9..70390d38c 100644 --- a/src/Containers.jsx +++ b/src/Containers.jsx @@ -742,7 +742,7 @@ class Containers extends React.Component { {caption && - {caption} +

{caption}

{_("pod group")} {this.renderPodDetails(this.props.pods[section], podStatus)}
From f80868fb902eaaadd57157a6c1c9ba436d299acf Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Fri, 23 Sep 2022 13:27:05 +0200 Subject: [PATCH 3/3] test: ensure podman was stopped fully On Debian-testing restore_dir fails as it tries to copy a file which has come away. So it seems that systemctl stop did not fully succeed in stopping podman, ensure it does. --- test/check-application | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/check-application b/test/check-application index 28d435651..8d21cbc0d 100755 --- a/test/check-application +++ b/test/check-application @@ -50,7 +50,14 @@ class TestApplication(testlib.MachineCase): def setUp(self): super().setUp() m = self.machine - m.execute("systemctl stop podman.service; systemctl --now enable podman.socket") + m.execute(""" + systemctl stop podman.service; systemctl --now enable podman.socket + # Ensure podman is really stopped, as sometimes restore_dir flakes on Debian-testing + pkill -e -9 podman || true + while pgrep podman; do sleep 0.1; done + findmnt --list -otarget | grep /var/lib/containers/. | xargs -r umount + sync + """) # backup/restore pristine podman state, so that tests can run on existing testbeds self.restore_dir("/var/lib/containers", reboot_safe=True)