From 84939a7f03cffc0a5947c866b5bd32cff799d763 Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Tue, 24 Feb 2026 16:33:45 +0100 Subject: [PATCH 1/2] Rename imageContainerList to imageContainerMap The variable naming is a bit confusing as it is in fact an object. --- src/app.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app.jsx b/src/app.jsx index a94f43c9e..dcad0fdab 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -859,20 +859,20 @@ class Application extends React.Component { if (this.state.users.find(u => u.con === null && (u.uid === 0 || u.uid === null))) // not initialized yet return null; - let imageContainerList = {}; + let imageContainerMap = {}; if (this.state.containers !== null) { Object.keys(this.state.containers).forEach(c => { const container = this.state.containers[c]; const imageKey = makeKey(container.uid, container.Image); - if (!imageContainerList[imageKey]) - imageContainerList[imageKey] = []; - imageContainerList[imageKey].push({ + if (!imageContainerMap[imageKey]) + imageContainerMap[imageKey] = []; + imageContainerMap[imageKey].push({ container, stats: this.state.containersStats[makeKey(container.uid, container.Id)], }); }); } else - imageContainerList = null; + imageContainerMap = null; const loadingImages = this.state.users.find(u => u.con && !u.imagesLoaded); const loadingContainers = this.state.users.find(u => u.con && !u.containersLoaded); @@ -883,7 +883,7 @@ class Application extends React.Component { Date: Tue, 24 Feb 2026 16:07:51 +0100 Subject: [PATCH 2/2] Support listing volumes --- src/VolumeDeleteModal.jsx | 52 +++++++++++ src/Volumes.jsx | 188 ++++++++++++++++++++++++++++++++++++++ src/app.jsx | 81 +++++++++++++++- src/client.ts | 4 + src/podman.scss | 4 +- test/check-application | 43 +++++++++ 6 files changed, 366 insertions(+), 6 deletions(-) create mode 100644 src/VolumeDeleteModal.jsx create mode 100644 src/Volumes.jsx diff --git a/src/VolumeDeleteModal.jsx b/src/VolumeDeleteModal.jsx new file mode 100644 index 000000000..fcf3430fb --- /dev/null +++ b/src/VolumeDeleteModal.jsx @@ -0,0 +1,52 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +import React, { useState } from 'react'; + +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { + Modal, ModalBody, ModalFooter, ModalHeader +} from '@patternfly/react-core/dist/esm/components/Modal'; +import { useDialogs } from "dialogs.jsx"; + +import cockpit from 'cockpit'; + +import * as client from './client.js'; + +const _ = cockpit.gettext; + +export const VolumeDeleteModal = ({ con, volume }) => { + const [force, setForce] = useState(false); + const [reason, setReason] = useState(null); + const Dialogs = useDialogs(); + + const handleRemoveVolume = async () => { + setReason(null); + try { + await client.deleteVolume(con, volume.Name, force); + Dialogs.close(); + } catch (exc) { + setReason(exc.message); + setForce(true); + } + }; + + return ( + + + + {reason} + + + + + + + ); +}; diff --git a/src/Volumes.jsx b/src/Volumes.jsx new file mode 100644 index 000000000..1ffa70ef3 --- /dev/null +++ b/src/Volumes.jsx @@ -0,0 +1,188 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +import React from 'react'; + +import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card"; +import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js'; +import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection"; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex"; +import { cellWidth, SortByDirection } from '@patternfly/react-table'; +import { KebabDropdown } from "cockpit-components-dropdown.jsx"; +import { ListingTable } from "cockpit-components-table.jsx"; +import { useDialogs } from 'dialogs.jsx'; + +import cockpit from 'cockpit'; + +import { VolumeDeleteModal } from './VolumeDeleteModal.jsx'; +import * as utils from './util.js'; + +const _ = cockpit.gettext; + +const Volumes = ({ users, volumes, ownerFilter, textFilter, volumeContainerMap }) => { + const [isExpanded, setIsExpanded] = React.useState(false); + + const getUsedByText = (volume) => { + if (volumeContainerMap === null) { + return { title: _("unused"), count: 0 }; + } + const containers = volumeContainerMap[volume.key]; + if (containers !== undefined) { + const title = cockpit.format(cockpit.ngettext("$0 container", "$0 containers", containers.length), containers.length); + return { title, count: containers.length }; + } else { + return { title: _("unused"), count: 0 }; + } + }; + + const renderRow = volume => { + const { title: usedByText, count: usedByCount } = getUsedByText(volume); + const user = users.find(user => user.uid === volume.uid); + cockpit.assert(user, `User not found for volume uid ${volume.uid}`); + + const columns = [ + { title: volume.Name, header: true, props: { modifier: "breakWord" } }, + { + title: (volume.uid === 0) ? _("system") :
{_("user:")} {user.name}
, + props: { modifier: "nowrap" }, + sortKey: volume.key, + }, + { title: volume.Mountpoint, header: true, props: { modifier: "breakWord" } }, + { title: volume.Driver, header: true, props: { modifier: "breakWord" } }, + { title: , props: { className: "ignore-pixels" } }, + { title: {usedByText}, props: { className: "ignore-pixels", modifier: "nowrap" }, sortKey: usedByCount }, + { + title: , + props: { className: 'pf-v6-c-table__action content-action' } + }, + ]; + + return { + columns, + props: { + key: volume.key, + "data-row-id": volume.key, + "data-row-name": `${volume.uid === null ? 'user' : volume.uid}-${volume.Name}` + }, + }; + }; + + const sortRows = (rows, direction, idx) => { + // Name / Owner / Mount Point / Driver / Created / Used by + const isNumeric = idx == 4 || idx == 5; + const sortedRows = rows.sort((a, b) => { + const aitem = a.columns[idx].sortKey ?? a.columns[idx].title; + const bitem = b.columns[idx].sortKey ?? b.columns[idx].title; + if (isNumeric) { + return bitem - aitem; + } else { + return aitem.localeCompare(bitem); + } + }); + return direction === SortByDirection.asc ? sortedRows : sortedRows.reverse(); + }; + + const columnTitles = [ + { title: _("Name"), transforms: [cellWidth(20)], sortable: true }, + { title: _("Owner"), sortable: true }, + { title: _("Mount point"), sortable: true }, + { title: _("Driver"), sortable: true }, + { title: _("Created"), sortable: true }, + { title: _("Used by"), sortable: true }, + ]; + + let emptyCaption = _("No volumes"); + if (volumes === null) { + emptyCaption = _("Loading..."); + } else if (textFilter.length > 0) { + emptyCaption = _("No volumes that match the current filter"); + } + + const volumeKeys = Object.keys(volumes || {}); + const volumesTotal = volumeKeys.length; + + let filtered = [...volumeKeys]; + if (volumes !== null) { + if (ownerFilter !== "all") { + filtered = filtered.filter(id => { + if (ownerFilter === "user") + return volumes[id].uid === null; + return volumes[id].uid === ownerFilter; + }); + } + + if (textFilter.length > 0) { + filtered = filtered.filter(id => { + const name = volumes[id].Name; + return name.toLowerCase().includes(textFilter); + }); + } + } + + const rows = filtered.map(name => renderRow(volumes[name])); + + const cardBody = ( + + ); + + const volumesTitleStats = ( +
+ {cockpit.format(cockpit.ngettext("$0 volume total", "$0 volumes total", volumesTotal), volumesTotal)} +
+ ); + + return ( + + + + + + +

{_("Volumes")}

+
+ {volumesTitleStats} +
+
+
+
+ + {volumes && Object.keys(volumes).length + ? setIsExpanded(!isExpanded)} + isExpanded={isExpanded}> + {cardBody} + + : cardBody} + +
+ ); +}; + +const VolumeActions = ({ con, volume }) => { + const Dialogs = useDialogs(); + + const removeVolume = () => { + Dialogs.show(); + }; + + const dropdownActions = [ + + {_("Delete")} + + ]; + + return ; +}; + +export default Volumes; diff --git a/src/app.jsx b/src/app.jsx index dcad0fdab..b7fd9e4bf 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -22,6 +22,7 @@ import { superuser } from "superuser"; import ContainerHeader from './ContainerHeader.jsx'; import Containers from './Containers.jsx'; import Images from './Images.jsx'; +import Volumes from './Volumes.jsx'; import * as client from './client.js'; import detect_quadlets from './detect-quadlets.py'; import rest from './rest.js'; @@ -46,7 +47,7 @@ class Application extends React.Component { constructor(props) { super(props); this.state = { - // currently connected services per user: { con, uid, name, dbus: { client, subscription }, imagesLoaded, containersLoaded, podsLoaded, quadletsLoaded } + // currently connected services per user: { con, uid, name, dbus: { client, subscription }, imagesLoaded, containersLoaded, podsLoaded, quadletsLoaded, volumesLoaded } // start with dummy state to wait for initialization users: [{ con: null, uid: 0, name: _("system"), dbus: null }, { con: null, uid: null, name: _("user"), dbus: null }], images: null, @@ -60,6 +61,7 @@ class Application extends React.Component { quadletContainers: {}, // { "$uid-$name-pod.service": { source_path, name } } quadletPods: {}, + volumes: null, textFilter: "", ownerFilter: "all", dropDownValue: 'Everything', @@ -196,6 +198,29 @@ class Application extends React.Component { .catch(e => console.warn("initContainers uid", con.uid, "getContainers failed:", e.toString())); } + initVolumes(con) { + return client.getVolumes(con) + .then(volumesList => { + this.setState(prevState => { + const copyVolumes = {}; + Object.entries(prevState.volumes || {}).forEach(([id, volume]) => { + if (volume.uid !== con.uid) + copyVolumes[id] = volume; + }); + + for (const volume of volumesList || []) { + volume.uid = con.uid; + volume.key = makeKey(con.uid, volume.Name); + copyVolumes[volume.key] = volume; + } + + const users = prevState.users.map(u => u.uid === con.uid ? { ...u, volumesLoaded: true } : u); + return { volumes: copyVolumes, users }; + }); + }) + .catch(ex => console.warn("Failed to fetch volumes for uid", con.uid, ":", JSON.stringify(ex))); + } + updateImages(con) { client.getImages(con) .then(reply => { @@ -418,6 +443,23 @@ class Application extends React.Component { } } + handleVolumeEvent(event, con) { + switch (event.Action) { + case 'create': + this.initVolumes(con); + break; + case 'remove': + this.setState(prevState => { + const volumes = { ...prevState.volumes }; + delete volumes[makeKey(con.uid, event.Actor.Attributes.name)]; + return { volumes }; + }); + break; + default: + console.warn('Unhandled event type ', event.Type, event.Action); + } + } + handleEvent(event, con) { switch (event.Type) { case 'container': @@ -429,6 +471,9 @@ class Application extends React.Component { case 'pod': this.handlePodEvent(event, con); break; + case 'volume': + this.handleVolumeEvent(event, con); + break; default: console.warn('Unhandled event type ', event.Type); } @@ -436,7 +481,7 @@ class Application extends React.Component { cleanupAfterService(con) { debug("cleanupAfterService", con.uid, "current owner filter:", this.state.ownerFilter); - ["images", "containers", "pods"].forEach(t => { + ["images", "containers", "pods", "volumes"].forEach(t => { if (this.state[t]) this.setState(prevState => { const copy = {}; @@ -638,7 +683,7 @@ class Application extends React.Component { const reply = await client.getInfo(con); this.setState(prevState => { const users = prevState.users.filter(u => u.uid !== uid); - users.push({ con, uid, name: username, containersLoaded: false, podsLoaded: false, imagesLoaded: false, quadletsLoaded: false }); + users.push({ con, uid, name: username, containersLoaded: false, podsLoaded: false, imagesLoaded: false, quadletsLoaded: false, volumesLoaded: false }); // keep a nice sort order for dialogs users.sort(compareUser); debug("init uid", uid, "username", username, "new users:", users); @@ -660,6 +705,7 @@ class Application extends React.Component { this.updateImages(con); this.initContainers(con); this.initQuadlets(con); + this.initVolumes(con); this.subscribeDaemonReload(con); this.updatePods(con); @@ -860,6 +906,7 @@ class Application extends React.Component { return null; let imageContainerMap = {}; + let volumeContainerMap = {}; if (this.state.containers !== null) { Object.keys(this.state.containers).forEach(c => { const container = this.state.containers[c]; @@ -870,14 +917,28 @@ class Application extends React.Component { container, stats: this.state.containersStats[makeKey(container.uid, container.Id)], }); + + for (const mount of (container.Mounts || [])) { + if (mount.Type != "volume") + continue; + + const volumeKey = makeKey(container.uid, mount.Name); + if (!volumeContainerMap[volumeKey]) + volumeContainerMap[volumeKey] = []; + + volumeContainerMap[volumeKey].push(volumeKey); + } }); - } else + } else { imageContainerMap = null; + volumeContainerMap = null; + } const loadingImages = this.state.users.find(u => u.con && !u.imagesLoaded); const loadingContainers = this.state.users.find(u => u.con && !u.containersLoaded); const loadingPods = this.state.users.find(u => u.con && !u.podsLoaded); const loadingQuadlets = this.state.users.find(u => u.con && !u.quadletsLoaded); + const loadingVolumes = this.state.users.find(u => u.con && !u.volumesLoaded); const imageList = ( ); + const volumeList = ( + + ); + const notificationList = ( {this.state.notifications.map((notification, index) => { @@ -953,6 +1025,7 @@ class Application extends React.Component { {imageList} + {volumeList} {containerList} diff --git a/src/client.ts b/src/client.ts index 4b5f347fb..cf3719c7d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -161,3 +161,7 @@ export const imageHistory = (con: Connection, id: string) => podmanJson(con, `li export const imageExists = (con: Connection, id: string) => podmanCall(con, `libpod/images/${id}/exists`, "GET", {}); export const containerExists = (con: Connection, id: string) => podmanCall(con, `libpod/containers/${id}/exists`, "GET", {}); + +export const getVolumes = (con: Connection) => podmanJson(con, "libpod/volumes/json", "GET", {}); + +export const deleteVolume = (con: Connection, name: string, force: boolean = false) => podmanCall(con, `libpod/volumes/${name}`, "DELETE", { force }); diff --git a/src/podman.scss b/src/podman.scss index dee11e5d0..d293b0151 100644 --- a/src/podman.scss +++ b/src/podman.scss @@ -5,11 +5,11 @@ // For pf-v6-line-clamp @use "@patternfly/patternfly/sass-utilities/mixins.scss"; -#app .pf-v6-c-card.containers-containers, #app .pf-v6-c-card.containers-images { +#app .pf-v6-c-card.containers-containers, #app .pf-v6-c-card.containers-images, #app .pf-v6-c-card.containers-volumes { @extend .ct-card; } -#containers-images, #containers-containers { +#containers-images, #containers-containers, #containers-volumes { // Decrease padding for the image/container toggle button list .pf-v6-c-table.pf-m-compact .pf-v6-c-table__toggle { padding-inline-start: 0; diff --git a/test/check-application b/test/check-application index 7d7a219c5..f6a2f1920 100755 --- a/test/check-application +++ b/test/check-application @@ -233,6 +233,10 @@ class TestApplication(testlib.MachineCase): name = f"{'0' if system else 'user'}-{image}" return f'#containers-images tbody tr[data-row-name="{name}"]' + def getVolumeSelector(self, volume: str, *, system: bool = True) -> str: + name = f"{'0' if system else 'user'}-{volume}" + return f'#containers-volumes tbody tr[data-row-name="{name}"]' + def openCreateContainerDialog(self, image: str, *, system: bool = True) -> None: self.browser.click(f'{self.getImageSelector(image, system=system)} .ct-container-create') @@ -3739,6 +3743,45 @@ ContainerName=guitarq1 b.wait_in_text(f"{sel} + tr", "sleep infinity") b.wait_not_in_text(f"{sel} + tr", "systemd service") + def _testVolumes(self, *, system: bool = False) -> None: + b = self.browser + volume_name = "nextcloud-data" + unused_volume = "unused" + + def showVolumes() -> None: + if b.attr("#containers-volumes .pf-v6-c-expandable-section__toggle button", "aria-expanded") == 'false': + b.click("#containers-volumes .pf-v6-c-expandable-section__toggle button") + + self.execute(f"podman volume create {volume_name}", system=system) + self.execute(f"podman volume create {unused_volume}", system=system) + self.execute(f"podman run -d --name nextcloud --stop-timeout 0 --volume {volume_name}:/data {IMG_ALPINE} sh", + system=system) + + self.login() + b.wait_in_text("#containers-volumes", "2 volumes total") + showVolumes() + + unused_sel = self.getVolumeSelector(unused_volume, system=system) + b.wait_in_text(f"{unused_sel} td[data-label='Used by']", 'unused') + # TODO: helper for $currentuser + b.wait_in_text(f"{unused_sel} td[data-label='Owner']", 'system' if system else 'admin') + + nextcloud_sel = self.getVolumeSelector(volume_name, system=system) + b.wait_in_text(f"{nextcloud_sel} td[data-label='Used by']", '1 container') + + # Delete unused volume + b.click(f"{unused_sel} td.content-action button") + b.click(".pf-v6-c-menu button.btn-delete") + b.wait_text(".pf-v6-c-modal-box__title-text", "Delete unused volume?") + self.confirm_modal("Delete volume") + b.wait_not_present(unused_sel) + + def testVolumesUser(self) -> None: + self._testVolumes(system=False) + + def testVolumesSystem(self) -> None: + self._testVolumes(system=True) + if __name__ == '__main__': testlib.test_main()