diff --git a/src/Volumes.jsx b/src/Volumes.jsx new file mode 100644 index 000000000..b552e0f97 --- /dev/null +++ b/src/Volumes.jsx @@ -0,0 +1,149 @@ +import React from 'react'; + +import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card"; +import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection"; +import { Text, TextVariants } from "@patternfly/react-core/dist/esm/components/Text"; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex"; +import { cellWidth, SortByDirection } from '@patternfly/react-table'; + +import cockpit from 'cockpit'; +import { ListingTable } from "cockpit-components-table.jsx"; + +import * as utils from './util.js'; + +const _ = cockpit.gettext; + +const initContainerVolumeMap = containers => { + const containerVolumes = {}; + if (containers === null) + return containerVolumes; + + Object.keys(containers).forEach(id => { + const container = containers[id]; + for (const mount of container.Mounts) { + if (mount.Type === "volume") { + const volume_key = mount.Name + container.isSystem.toString(); + if (volume_key in containerVolumes) { + containerVolumes[volume_key] += 1; + } else { + containerVolumes[volume_key] = 1; + } + } + } + }); + + return containerVolumes; +}; + +const Volumes = ({ user, volumes, containers }) => { + const [isExpanded, setIsExpanded] = React.useState(false); + const containerVolumes = initContainerVolumeMap(containers); + + const getUsedByText = volume => { + const containers = containerVolumes[volume.Name + volume.isSystem.toString()]; + if (containers !== undefined) { + const title = cockpit.format(cockpit.ngettext("$0 container", "$0 containers", containers), containers); + return { title, count: containers.length }; + } else { + return { title: _("unused"), count: 0 }; + } + }; + + const renderRow = volume => { + const { title: usedByText, count: usedByCount } = getUsedByText(volume); + + const columns = [ + { title: volume.Name, header: true, props: { modifier: "breakWord" } }, + { + title: volume.isSystem ? _("system") :
{_("user:")} {user}
, + props: { className: "ignore-pixels", modifier: "nowrap" }, + sortKey: volume.isSystem.toString(), + }, + { 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" }, + }, + ]; + + return { + columns, + props: { + key: volume.Name + volume.isSystem.toString(), + "data-row-id": volume.Name + volume.isSystem.toString(), + }, + }; + }; + + const sortRows = (rows, direction, idx) => { + const isNumeric = idx == 4; + 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 }, + ]; + + const rows = Object.keys(volumes || {}).map(name => renderRow(volumes[name])); + const volumesTotal = Object.keys(volumes || {}).length; + + 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} + + + ); +}; + +export default Volumes; diff --git a/src/app.jsx b/src/app.jsx index 2f3733f03..beff085e4 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -34,6 +34,8 @@ import { superuser } from "superuser"; import ContainerHeader from './ContainerHeader.jsx'; import Containers from './Containers.jsx'; import Images from './Images.jsx'; +import { Volume } from './Volume.jsx'; +import Volumes from './Volumes.jsx'; import * as client from './client.js'; import { WithPodmanInfo } from './util.js'; @@ -52,10 +54,13 @@ class Application extends React.Component { containers: null, containersFilter: "all", containersStats: {}, + volumes: null, userContainersLoaded: null, systemContainersLoaded: null, userPodsLoaded: null, systemPodsLoaded: null, + userVolumesLoaded: null, + systemVolumesLoaded: null, userServiceExists: false, textFilter: "", ownerFilter: "all", @@ -203,6 +208,31 @@ class Application extends React.Component { .catch(console.log); } + initVolumes(system) { + return client.getVolumes(system) + .then(volumesList => { + this.setState(prevState => { + const copyVolumes = {}; + Object.entries(prevState.volumes || {}).forEach(([id, volume]) => { + if (volume.isSystem !== system) { + copyVolumes[id] = volume; + } + }); + + for (const volume of volumesList) { + volume.isSystem = system; + copyVolumes[volume.Name + system.toString()] = volume; + } + + return { + volumes: copyVolumes, + [system ? "systemVolumesLoaded" : "userVolumesLoaded"]: true, + }; + }); + }) + .catch(console.log); + } + updateImages(system) { client.getImages(system) .then(reply => { @@ -424,6 +454,23 @@ class Application extends React.Component { } } + handleVolumeEvent(event, system) { + switch (event.Action) { + case 'create': + this.initVolumes(system); + break; + case 'remove': + this.setState(prevState => { + const volumes = { ...prevState.volumes }; + delete volumes[event.Actor.Attributes.name + system.toString()]; + return { volumes }; + }); + break; + default: + console.warn('Unhandled event type ', event.Type, event.Action); + } + } + handleEvent(event, system) { switch (event.Type) { case 'container': @@ -435,6 +482,9 @@ class Application extends React.Component { case 'pod': this.handlePodEvent(event, system); break; + case 'volume': + this.handleVolumeEvent(event, system); + break; default: console.warn('Unhandled event type ', event.Type); } @@ -465,6 +515,7 @@ class Application extends React.Component { }); this.updateImages(system); this.initContainers(system); + this.initVolumes(system); this.updatePods(system); client.streamEvents(system, message => this.handleEvent(message, system)) @@ -736,6 +787,15 @@ class Application extends React.Component { /> ); + const volumeList = ( + + ); + const notificationList = ( {this.state.notifications.map((notification, index) => { @@ -780,6 +840,7 @@ class Application extends React.Component { { this.state.showStartService ? startService : null } {imageList} + {volumeList} {containerList} diff --git a/src/client.js b/src/client.js index a6455429c..c7031bca1 100644 --- a/src/client.js +++ b/src/client.js @@ -181,3 +181,5 @@ export const imageHistory = (system, id) => podmanJson(`libpod/images/${id}/hist export const imageExists = (system, id) => podmanCall("libpod/images/" + id + "/exists", "GET", {}, system); export const containerExists = (system, id) => podmanCall("libpod/containers/" + id + "/exists", "GET", {}, system); + +export const getVolumes = system => podmanJson("libpod/volumes/json", "GET", {}, system); diff --git a/src/podman.scss b/src/podman.scss index fdc05c821..ee72e3612 100644 --- a/src/podman.scss +++ b/src/podman.scss @@ -6,7 +6,7 @@ // For pf-u-disabled-color-100 @import "@patternfly/patternfly/utilities/Text/text.css"; -#app .pf-v5-c-card.containers-containers, #app .pf-v5-c-card.containers-images { +#app .pf-v5-c-card.containers-containers, #app .pf-v5-c-card.containers-images, #app .pf-v5-c-card.containers-volumes { @extend .ct-card; } @@ -14,7 +14,7 @@ white-space: break-spaces; } -#containers-images, #containers-containers { +#containers-images, #containers-containers, #containers-volumes { // Decrease padding for the image/container toggle button list .pf-v5-c-table.pf-m-compact .pf-v5-c-table__toggle { padding-inline-start: 0;