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 a94f43c9e..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);
@@ -859,31 +905,46 @@ 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 = {};
+ let volumeContainerMap = {};
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)],
});
+
+ 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
- imageContainerList = null;
+ } 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()