+
+
+ >}
+ />
+ );
+ }
+}
diff --git a/src/VolumeDetails.jsx b/src/VolumeDetails.jsx
new file mode 100644
index 000000000..efe5d098b
--- /dev/null
+++ b/src/VolumeDetails.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import cockpit from 'cockpit';
+
+import { DescriptionList, DescriptionListTerm, DescriptionListDescription, DescriptionListGroup, List, ListItem } from "@patternfly/react-core";
+
+import VolumeUsedBy from './VolumeUsedBy.jsx';
+const _ = cockpit.gettext;
+
+const render_map = (labels) => {
+ if (!labels)
+ return null;
+
+ const result = Object.entries(labels).map(([key, value]) => {
+ return (
+
+ { key }: { value }
+
+ );
+ });
+
+ return {result}
;
+};
+
+const VolumeDetails = ({ volume, containers, showAll }) => {
+ const labels = volume.Labels && render_map(volume.Labels);
+ const options = volume.Options && Object.keys(volume.Options || {}).join(', ');
+
+ return (
+
+ {Object.entries(volume.Labels).length !== 0 &&
+
+ {_("Labels")}
+ {labels}
+
+ }
+ {options &&
+
+ {_("Mount options")}
+ {options}
+
+ }
+ {containers &&
+
+ {_("Used by")}
+
+
+ }
+
+ );
+};
+
+export default VolumeDetails;
diff --git a/src/VolumeUsedBy.jsx b/src/VolumeUsedBy.jsx
new file mode 100644
index 000000000..28e28512a
--- /dev/null
+++ b/src/VolumeUsedBy.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import cockpit from 'cockpit';
+import { Button, Badge, Flex, List, ListItem } from "@patternfly/react-core";
+
+const _ = cockpit.gettext;
+
+const VolumeUsedBy = ({ containers, showAll }) => {
+ if (containers === null)
+ return _("Loading...");
+ if (containers === undefined)
+ return _("No containers are using this volume");
+
+ return (
+
+ {containers.map(c => {
+ const container = c.container;
+ const isRunning = container.State == "running";
+ return (
+
+
+
+ {isRunning && {_("Running")}}
+
+
+ );
+ })}
+
+ );
+};
+
+export default VolumeUsedBy;
diff --git a/src/Volumes.css b/src/Volumes.css
new file mode 100644
index 000000000..4c5b61516
--- /dev/null
+++ b/src/Volumes.css
@@ -0,0 +1,17 @@
+/* Danger dropdown items should be red */
+.pf-c-dropdown__menu-item.pf-m-danger {
+ color: var(--pf-global--danger-color--200);
+}
+
+#containers-volumes .pf-c-table.pf-m-compact .pf-c-table__action {
+ --pf-c-table__action--PaddingTop: 0.5rem;
+ --pf-c-table__action--PaddingBottom: 0.5rem;
+}
+
+.containers-volumes .pf-c-empty-state__body {
+ margin-top: 0px;
+}
+
+.containers-volumes .pf-c-empty-state {
+ padding-top: 0px;
+}
\ No newline at end of file
diff --git a/src/Volumes.jsx b/src/Volumes.jsx
new file mode 100644
index 000000000..45d4acabb
--- /dev/null
+++ b/src/Volumes.jsx
@@ -0,0 +1,311 @@
+import React, { useState } from 'react';
+import {
+ Card, CardBody, CardHeader,
+ Dropdown, DropdownItem,
+ Flex, FlexItem,
+ ExpandableSection,
+ KebabToggle,
+ Text, TextVariants,
+ ToolbarItem, Button
+} from '@patternfly/react-core';
+
+import cockpit from 'cockpit';
+import { ListingTable } from "cockpit-components-table.jsx";
+import { ListingPanel } from 'cockpit-components-listing-panel.jsx';
+import VolumeDetails from './VolumeDetails.jsx';
+import { VolumeDeleteModal } from './VolumeDeleteModal.jsx';
+import PruneUnusedVolumesModal from './PruneUnusedVolumesModal.jsx';
+import ForceRemoveModal from './ForceRemoveModal.jsx';
+import { VolumeCreateModal } from './VolumeCreateModal.jsx';
+import * as client from './client.js';
+import * as utils from './util.js';
+
+import './Volumes.css';
+import '@patternfly/react-styles/css/utilities/Sizing/sizing.css';
+
+const _ = cockpit.gettext;
+
+class Volumes extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isExpanded: false,
+ showVolumeCreateModal: false,
+ };
+
+ this.renderRow = this.renderRow.bind(this);
+ }
+
+ onOpenPruneUnusedVolumesDialog = () => {
+ this.setState({ showPruneUnusedVolumesModal: true });
+ }
+
+ calculateStats = () => {
+ const { volumes, volumeContainerList } = this.props;
+ const unusedVolumes = [];
+ const volumeStats = {
+ volumesTotal: 0,
+ unusedTotal: 0,
+ };
+
+ if (volumeContainerList === null) {
+ return { volumeStats, unusedVolumes };
+ }
+
+ if (volumes !== null) {
+ Object.keys(volumes).forEach(name => {
+ const volume = volumes[name];
+ volumeStats.volumesTotal += 1;
+
+ const usedBy = volumeContainerList[volume.Name + volume.isSystem.toString()];
+ if (usedBy === undefined) {
+ volumeStats.unusedTotal += 1;
+ unusedVolumes.push(volume);
+ }
+ });
+ }
+
+ return { volumeStats, unusedVolumes };
+ }
+
+ renderRow(volume) {
+ const tabs = [];
+
+ const columns = [
+ { title: volume.Name.length === 64 ? utils.truncate_id(volume.Name) : volume.Name },
+ { title: { volume.Mountpoint }, props: { modifier: "breakWord" } },
+ { title: volume.isSystem ? _("system") : {_("user:")} {this.props.user}
, props: { modifier: "nowrap" } },
+ { title: utils.localize_date(volume.CreatedAt), props: { modifier: "nowrap" } },
+ { title: volume.Driver, props: { modifier: "nowrap" } },
+ {
+ title: ,
+ props: { className: 'pf-c-table__action content-action' }
+ },
+ ];
+
+ tabs.push({
+ name: _("Details"),
+ renderer: VolumeDetails,
+ data: {
+ volume: volume,
+ containers: this.props.volumeContainerList !== null ? this.props.volumeContainerList[volume.Name + volume.isSystem.toString()] : null,
+ showAll: this.props.showAll,
+ }
+ });
+ return {
+ expandedContent: ,
+ columns: columns,
+ props: {
+ key :volume.Name + volume.isSystem.toString(),
+ "data-row-id": volume.Name + volume.isSystem.toString(),
+ },
+ };
+ }
+
+ render() {
+ const columnTitles = [
+ _("Name"),
+ _("Mountpoint"),
+ _("Owner"),
+ _("Created"),
+ _("Driver"),
+ ];
+ let emptyCaption = _("No volumes");
+ if (this.props.volumes === null)
+ emptyCaption = "Loading...";
+ else if (this.props.textFilter.length > 0)
+ emptyCaption = _("No volumes that match the current filter");
+
+ let filtered = [];
+ if (this.props.volumes !== null) {
+ filtered = Object.keys(this.props.volumes).filter(id => {
+ if (this.props.userServiceAvailable && this.props.systemServiceAvailable && this.props.ownerFilter !== "all") {
+ if (this.props.ownerFilter === "system" && !this.props.volumes[id].isSystem)
+ return false;
+ if (this.props.ownerFilter !== "system" && this.props.volumes[id].isSystem)
+ return false;
+ }
+ return true;
+ });
+ }
+
+ if (this.props.textFilter.length > 0) {
+ const lcf = this.props.textFilter.toLowerCase();
+ filtered = filtered.filter(id => this.props.volumes[id].Name.toLowerCase().indexOf(lcf) >= 0);
+ }
+
+ filtered.sort((a, b) => {
+ // User volumes are in front of system ones
+ if (this.props.volumes[a].isSystem !== this.props.volumes[b].isSystem)
+ return this.props.volumes[a].isSystem ? 1 : -1;
+ const name_a = this.props.volumes[a].name;
+ const name_b = this.props.volumes[b].name;
+ if (name_a === "")
+ return 1;
+ if (name_b === "")
+ return -1;
+ return name_a > name_b ? 1 : -1;
+ });
+
+ const volumeRows = filtered.map(id => this.renderRow(this.props.volumes[id]));
+
+ const cardBody = (
+ <>
+
+ >
+ );
+
+ const { volumeStats, unusedVolumes } = this.calculateStats();
+ const volumeTitleStats = (
+ <>
+
+ {cockpit.format(cockpit.ngettext("$0 volume total", "$0 volumes total", volumeStats.volumesTotal), volumeStats.volumesTotal)}
+
+ >
+ );
+
+ return (
+
+
+
+
+
+ {_("Volumes")}
+ {volumeTitleStats}
+
+
+
+
+
+ {this.state.showVolumeCreateModal &&
+ this.setState({ showVolumeCreateModal: false })}
+ selinuxAvailable={this.props.selinuxAvailable}
+ podmanRestartAvailable={this.props.podmanRestartAvailable}
+ systemServiceAvailable={this.props.systemServiceAvailable}
+ userServiceAvailable={this.props.userServiceAvailable}
+ onAddNotification={this.props.onAddNotification}
+ /> }
+
+
+
+
+
+
+ {filtered.length
+ ? this.setState({ isExpanded: !this.state.isExpanded })}
+ isExpanded={this.state.isExpanded}>
+ {cardBody}
+
+ : cardBody}
+
+ {this.state.showPruneUnusedVolumesModal &&
+ this.setState({ showPruneUnusedVolumesModal: false })}
+ unusedVolumes={unusedVolumes}
+ onAddNotification={this.props.onAddNotification}
+ userServiceAvailable={this.props.userServiceAvailable}
+ systemServiceAvailable={this.props.systemServiceAvailable} /> }
+
+ );
+ }
+}
+
+const VolumeOverActions = ({ handlePruneUsedVolumes, unusedVolumes }) => {
+ const [isActionsKebabOpen, setIsActionsKebabOpen] = useState(false);
+
+ return (
+ setIsActionsKebabOpen(!isActionsKebabOpen)} id="volume-actions-dropdown" />}
+ isOpen={isActionsKebabOpen}
+ isPlain
+ position="right"
+ dropdownItems={[
+
+ {_("Prune unused volumes")}
+ ,
+ ]} />
+ );
+};
+
+const VolumeActions = ({ volume, onAddNotification }) => {
+ const [showVolumeDeleteModal, setShowVolumeDeleteModal] = useState(false);
+ const [showVolumeDeleteErrorModal, setShowVolumeDeleteErrorModal] = useState(false);
+ const [volumeDeleteErrorMsg, setVolumeDeleteErrorMsg] = useState();
+ const [isActionsKebabOpen, setIsActionsKebabOpen] = useState(false);
+
+ const handleRemoveVolume = () => {
+ setShowVolumeDeleteModal(false);
+ client.delVolume(volume.isSystem, volume.Name, false)
+ .catch(ex => {
+ setVolumeDeleteErrorMsg(ex.message);
+ setShowVolumeDeleteErrorModal(true);
+ });
+ };
+
+ const handleForceRemoveVolume = () => {
+ return client.delVolume(volume.isSystem, volume.Name, true)
+ .then(reply => setShowVolumeDeleteErrorModal(false))
+ .catch(ex => {
+ const error = cockpit.format(_("Failed to force remove volume $0"), volume.Name);
+ onAddNotification({ type: 'danger', error, errorDetail: ex.message });
+ throw ex;
+ });
+ };
+
+ const extraActions = (
+ setIsActionsKebabOpen(!isActionsKebabOpen)} />}
+ isOpen={isActionsKebabOpen}
+ isPlain
+ position="right"
+ dropdownItems={[
+ setShowVolumeDeleteModal(true)}>
+ {_("Delete")}
+
+ ]} />
+ );
+
+ return (
+ <>
+ {extraActions}
+ {showVolumeDeleteErrorModal &&
+ setShowVolumeDeleteErrorModal(false)}
+ handleForceRemove={handleForceRemoveVolume}
+ reason={volumeDeleteErrorMsg} /> }
+ {showVolumeDeleteModal &&
+ setShowVolumeDeleteModal(false)}
+ handleRemoveVolume={handleRemoveVolume} /> }
+ >
+ );
+};
+
+export default Volumes;
diff --git a/src/app.jsx b/src/app.jsx
index 1910e7083..456df45af 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -32,6 +32,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 { WithPodmanInfo } from './util.js';
@@ -50,6 +51,9 @@ class Application extends React.Component {
containers: null,
containersFilter: "all",
containersStats: {},
+ volumes: null,
+ userVolumesLoaded: false,
+ systemVolumesLoaded: false,
userContainersLoaded: null,
systemContainersLoaded: null,
userPodsLoaded: null,
@@ -73,6 +77,8 @@ class Application extends React.Component {
this.onDismissNotification = this.onDismissNotification.bind(this);
this.onFilterChanged = this.onFilterChanged.bind(this);
this.onOwnerChanged = this.onOwnerChanged.bind(this);
+ this.updateVolumesAfterEvent = this.updateVolumesAfterEvent.bind(this);
+ this.handleVolumeEvent = this.handleVolumeEvent.bind(this);
this.onContainerFilterChanged = this.onContainerFilterChanged.bind(this);
this.updateContainer = this.updateContainer.bind(this);
this.startService = this.startService.bind(this);
@@ -228,6 +234,33 @@ class Application extends React.Component {
});
}
+ updateVolumesAfterEvent(system) {
+ client.getVolumes(system)
+ .then(reply => {
+ this.setState(prevState => {
+ // Copy only volumes that could not be deleted with this event
+ // So when event from system come, only copy user volumes and vice versa
+ const copyVolumes = {};
+ Object.entries(prevState.volumes || {}).forEach(([Id, volume]) => {
+ if (volume.isSystem !== system)
+ copyVolumes[Id] = volume;
+ });
+ Object.entries(reply).forEach(([Id, volume]) => {
+ volume.isSystem = system;
+ copyVolumes[volume.Name + system.toString()] = volume;
+ });
+
+ return {
+ volumes: copyVolumes,
+ [system ? "systemVolumesLoaded" : "userVolumesLoaded"]: true
+ };
+ });
+ })
+ .catch(ex => {
+ console.warn("Failed to do Update Volumes:", JSON.stringify(ex));
+ });
+ }
+
updatePods(system) {
return client.getPods(system)
.then(reply => {
@@ -325,6 +358,18 @@ class Application extends React.Component {
}
}
+ handleVolumeEvent(event, system) {
+ switch (event.Action) {
+ case 'remove':
+ case 'prune':
+ case 'create':
+ this.updateVolumesAfterEvent(system);
+ break;
+ default:
+ console.warn('Unhandled event type ', event.Type, event.Action);
+ }
+ }
+
handleContainerEvent(event, system) {
const id = event.Actor.ID;
@@ -430,6 +475,9 @@ class Application extends React.Component {
case 'image':
this.handleImageEvent(event, system);
break;
+ case 'volume':
+ this.handleVolumeEvent(event, system);
+ break;
case 'pod':
this.handlePodEvent(event, system);
break;
@@ -439,7 +487,7 @@ class Application extends React.Component {
}
cleanupAfterService(system, key) {
- ["images", "containers", "pods"].forEach(t => {
+ ["images", "volumes", "containers", "pods"].forEach(t => {
if (this.state[t])
this.setState(prevState => {
const copy = {};
@@ -461,6 +509,7 @@ class Application extends React.Component {
registries: reply.registries,
cgroupVersion: reply.host.cgroupVersion,
});
+ this.updateVolumesAfterEvent(system);
this.updateImages(system);
this.initContainers(system);
this.updatePods(system);
@@ -490,6 +539,7 @@ class Application extends React.Component {
[system ? "systemServiceAvailable" : "userServiceAvailable"]: false,
[system ? "systemContainersLoaded" : "userContainersLoaded"]: true,
[system ? "systemImagesLoaded" : "userImagesLoaded"]: true,
+ [system ? "systemVolumesLoaded" : "userVolumesLoaded"]: true,
[system ? "systemPodsLoaded" : "userPodsLoaded"]: true
});
});
@@ -507,6 +557,7 @@ class Application extends React.Component {
} else {
this.setState({
userImagesLoaded: true,
+ userVolumesLoaded: true,
userContainersLoaded: true,
userPodsLoaded: true,
userServiceExists: false
@@ -596,7 +647,8 @@ class Application extends React.Component {
this.setState({
systemServiceAvailable: false,
systemContainersLoaded: true,
- systemImagesLoaded: true
+ systemImagesLoaded: true,
+ systemVolumesLoaded: true
});
console.warn("Failed to start system podman.socket:", JSON.stringify(err));
});
@@ -613,7 +665,8 @@ class Application extends React.Component {
userServiceAvailable: false,
userContainersLoaded: true,
userPodsLoaded: true,
- userImagesLoaded: true
+ userImagesLoaded: true,
+ userVolumesLoaded: true
});
console.warn("Failed to start user podman.socket:", JSON.stringify(err));
});
@@ -677,6 +730,23 @@ class Application extends React.Component {
} else
imageContainerList = null;
+ let volumeContainerList = {};
+ if (this.state.volumes !== null) {
+ Object.keys(this.state.volumes).forEach(c => {
+ const volume = this.state.volumes[c];
+ if (volumeContainerList[volume]) {
+ volumeContainerList[volume].push({
+ stats: this.state.volumes[volume.Name + volume.isSystem.toString()],
+ });
+ } else {
+ volumeContainerList[volume] = [{
+ stats: this.state.volumes[volume.Name + volume.isSystem.toString()]
+ }];
+ }
+ });
+ } else
+ volumeContainerList = null;
+
let startService = "";
const action = (
<>
@@ -713,11 +783,29 @@ class Application extends React.Component {
systemServiceAvailable={this.state.systemServiceAvailable}
/>
);
+ const volumeList = (
+ this.setState({ containersFilter: "all" }) }
+ user={this.state.currentUser}
+ userServiceAvailable={this.state.userServiceAvailable}
+ systemServiceAvailable={this.state.systemServiceAvailable}
+ registries={this.state.registries}
+ selinuxAvailable={this.state.selinuxAvailable}
+ podmanRestartAvailable={this.state.podmanRestartAvailable}
+ />
+ );
const containerList = (
{ this.state.showStartService ? startService : null }
{imageList}
+ {volumeList}
{containerList}
diff --git a/src/client.js b/src/client.js
index 41df29d27..bfa758cde 100644
--- a/src/client.js
+++ b/src/client.js
@@ -1,7 +1,7 @@
import rest from './rest.js';
const PODMAN_SYSTEM_ADDRESS = "/run/podman/podman.sock";
-export const VERSION = "/v1.12/";
+export const VERSION = "/v4.0/";
export function getAddress(system) {
if (system)
@@ -180,4 +180,55 @@ export const imageHistory = (system, id) => podmanJson(`libpod/images/${id}/hist
export const imageExists = (system, id) => podmanCall("libpod/images/" + id + "/exists", "GET", {}, system);
+export function inspectVolume(system, name) {
+ return new Promise((resolve, reject) => {
+ const options = {};
+ podmanCall("libpod/volumes/" + name + "/json", "GET", options, system)
+ .then(reply => resolve(JSON.parse(reply)))
+ .catch(reject);
+ });
+}
+
+export function getVolumes(system, name) {
+ return new Promise((resolve, reject) => {
+ const options = {};
+ if (name)
+ options.filters = JSON.stringify({ name: [name] });
+ podmanCall("libpod/volumes/json", "GET", options, system)
+ .then(reply => resolve(JSON.parse(reply)))
+ .catch(reject);
+ });
+}
+
+export function delVolume(system, name, force) {
+ return new Promise((resolve, reject) => {
+ const options = {
+ force: force,
+ };
+ podmanCall("libpod/volumes/" + name, "DELETE", options, system)
+ .then(reply => reply)
+ .catch(reject);
+ });
+}
+
+export function pruneUnusedVolumes(system) {
+ return new Promise((resolve, reject) => {
+ podmanCall("libpod/volumes/prune?all=true", "POST", {}, system).then(resolve)
+ .then(reply => resolve(JSON.parse(reply)))
+ .catch(reject);
+ });
+}
+
+export function volumeExists(system, name) {
+ return podmanCall("libpod/volumes/" + name + "/exists", "GET", {}, system);
+}
+
+export function createVolume(system, config) {
+ return new Promise((resolve, reject) => {
+ podmanCall("libpod/volumes/create", "POST", {}, system, JSON.stringify(config))
+ .then(reply => resolve(JSON.parse(reply)))
+ .catch(reject);
+ });
+}
+
export const containerExists = (system, id) => podmanCall("libpod/containers/" + id + "/exists", "GET", {}, system);
diff --git a/src/podman.scss b/src/podman.scss
index 6db864cb4..5d8fea38a 100644
--- a/src/podman.scss
+++ b/src/podman.scss
@@ -21,6 +21,13 @@
}
}
+#containers-volumes, #containers-containers {
+ // Decrease padding for the volume/container toggle button list
+ .pf-c-table.pf-m-compact .pf-c-table__toggle {
+ padding-left: 0px;
+ }
+}
+
@media screen and (max-width: 768px) {
// Badges should not stretch in mobile mode
.pf-v5-c-table [data-label] > .pf-v5-c-badge {
@@ -106,10 +113,22 @@
display: none;
}
+// Hide the header nav from the expandable rows - this should be better done with JS but the current cockpit-listing-panel implementation does not support this variant
+#containers-volumes .ct-listing-panel-head {
+ display: none;
+}
+
.ct-grey-text {
color: var(--pf-v5-global--Color--200);
}
+// Add borders to no pod containers list and images list
+.container-section.pf-m-plain tbody,
+.containers-images tbody,
+.containers-volumes tbody {
+ border: var(--pf-c-card--m-flat--BorderWidth) solid var(--pf-c-card--m-flat--BorderColor);
+}
+
.content-action {
text-align: end;
white-space: nowrap !important;
diff --git a/src/util.js b/src/util.js
index 688194aad..774b34696 100644
--- a/src/util.js
+++ b/src/util.js
@@ -44,6 +44,11 @@ export function localize_time(unix_timestamp) {
return formatRelative(unix_timestamp * 1000, Date.now(), { locale });
}
+export function localize_date(timestamp_string) {
+ const date = new Date(timestamp_string);
+ return date.toLocaleString(cockpit.language);
+}
+
export function format_memory_and_limit(usage, limit) {
if (usage === undefined || isNaN(usage))
return "";