From 37688ce2cf25495e0c0890c7e1870de6e12d6b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Str=C5=BE=C3=ADnek?= <35457537+strzinek@users.noreply.github.com> Date: Tue, 12 Apr 2022 09:18:12 +0200 Subject: [PATCH 1/8] Volumes support (#1) * First attempt to support podman volumes * Container details - Mounts * Container details - ENV * Volumes list * Create and remove volume * Volume details --- po/cs.po | 12 ++ src/ContainerDetails.jsx | 61 ++++++- src/Images.jsx | 2 +- src/PruneUnusedVolumesModal.jsx | 129 ++++++++++++++ src/VolumeCreateModal.jsx | 246 +++++++++++++++++++++++++ src/VolumeCreateModal.scss | 79 ++++++++ src/VolumeDeleteModal.jsx | 24 +++ src/VolumeDetails.jsx | 58 ++++++ src/VolumeUsedBy.jsx | 41 +++++ src/Volumes.css | 18 ++ src/Volumes.jsx | 307 ++++++++++++++++++++++++++++++++ src/app.jsx | 104 ++++++++++- src/client.js | 53 +++++- src/podman.scss | 17 +- src/util.js | 5 + 15 files changed, 1141 insertions(+), 15 deletions(-) create mode 100644 src/PruneUnusedVolumesModal.jsx create mode 100644 src/VolumeCreateModal.jsx create mode 100644 src/VolumeCreateModal.scss create mode 100644 src/VolumeDeleteModal.jsx create mode 100644 src/VolumeDetails.jsx create mode 100644 src/VolumeUsedBy.jsx create mode 100644 src/Volumes.css create mode 100644 src/Volumes.jsx diff --git a/po/cs.po b/po/cs.po index a5df64e52..4f2e54096 100644 --- a/po/cs.po +++ b/po/cs.po @@ -995,6 +995,18 @@ msgstr "nepoužito" msgid "user:" msgstr "uživatel:" +#: src/Volumes.jsx:128 +msgid "Volume" +msgstr "Svazek" + +#: src/Volumes.jsx:210 +msgid "Hide volumes" +msgstr "Skrýt svazky" + +#: src/Volumes.jsx:210 +msgid "Show volumes" +msgstr "Zobrazit svazky" + #~ msgid "Default with single selectable" #~ msgstr "Výchozí s jedním k výběru" diff --git a/src/ContainerDetails.jsx b/src/ContainerDetails.jsx index f18172797..eda25e1b7 100644 --- a/src/ContainerDetails.jsx +++ b/src/ContainerDetails.jsx @@ -33,15 +33,56 @@ const render_container_published_ports = (ports) => { return {result}; }; +const render_container_mounts = (mounts) => { + if (!mounts) + return null; + + const result = mounts.map(mount => { + const name = mount.Name; + const type = mount.Type; + const driver = mount.Driver; + const source = mount.Source; + const destination = mount.Destination; + return ( + + { name } → { destination }
+ { driver } { type }:
+ { source }
+
+ ); + }); + + return {result}; +}; + +const render_container_env = (env) => { + if (!env) + return null; + + const result = env.map(value => { + const keyvalue = value.split("="); + return ( + + { keyvalue[0] } = { keyvalue[1] } + + ); + }); + + return {result}; +}; + const ContainerDetails = ({ container, containerDetail }) => { const ports = render_container_published_ports(container.Ports); + const mounts = (containerDetail && containerDetail.Mounts.length !== 0) ? render_container_mounts(containerDetail.Mounts) : null; + const env = (containerDetail && containerDetail.Config) ? render_container_env(containerDetail.Config.Env) : null; const networkOptions = ( containerDetail && [ containerDetail.NetworkSettings.IPAddress, containerDetail.NetworkSettings.Gateway, containerDetail.NetworkSettings.MacAddress, - ports + ports, + mounts ].some(itm => !!itm) ); @@ -64,7 +105,7 @@ const ContainerDetails = ({ container, containerDetail }) => { - {networkOptions && + {networkOptions && {ports && {_("Ports")} {ports} @@ -83,6 +124,22 @@ const ContainerDetails = ({ container, containerDetail }) => { } } + + + {mounts && + {_("Mounts")} + {mounts} + } + + + + + {env && + {_("ENV")} + {env} + } + + diff --git a/src/Images.jsx b/src/Images.jsx index fd1ce7026..972c17b77 100644 --- a/src/Images.jsx +++ b/src/Images.jsx @@ -267,7 +267,7 @@ class Images extends React.Component { {_("Images")} - {imageTitleStats} + {imageTitleStats} diff --git a/src/PruneUnusedVolumesModal.jsx b/src/PruneUnusedVolumesModal.jsx new file mode 100644 index 000000000..cea5de072 --- /dev/null +++ b/src/PruneUnusedVolumesModal.jsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { Button, Checkbox, Flex, List, ListItem, Modal, } from '@patternfly/react-core'; +import cockpit from 'cockpit'; + +import * as client from './client.js'; + +import "@patternfly/patternfly/utilities/Spacing/spacing.css"; + +const _ = cockpit.gettext; + +function VolumeOptions({ volumes, checked, isSystem, handleChange, name, showCheckbox }) { + const [isExpanded, onToggle] = useState(false); + let shownVolumes = volumes; + if (!isExpanded) { + shownVolumes = shownVolumes.slice(0, 5); + } + + if (shownVolumes.length === 0) { + return null; + } + const listNameId = "list-" + name; + + return ( + + {showCheckbox && + + } + + {shownVolumes.map((volume, index) => + + {volume.Name} + + )} + {!isExpanded && volumes.length > 5 && + + } + + + ); +} + +class PruneUnusedVolumesModal extends React.Component { + constructor(props) { + super(props); + const isSystem = this.props.userServiceAvailable && this.props.systemServiceAvailable; + this.state = { + deleteUserVolumes: true, + deleteSystemVolumes: isSystem, + isPruning: false, + }; + } + + handlePruneUnusedVolumes = () => { + this.setState({ isPruning: true }); + + const actions = []; + if (this.state.deleteUserVolumes) { + actions.push(client.pruneUnusedVolumes(false)); + } + if (this.state.deleteSystemVolumes) { + actions.push(client.pruneUnusedVolumes(true)); + } + Promise.all(actions).then(this.props.close) + .catch(ex => { + const error = _("Failed to prune unused volumes"); + this.props.onAddNotification({ type: 'danger', error, errorDetail: ex.message }); + this.props.close(); + }); + } + + handleChange = (checked, event) => { + this.setState({ [event.target.name]: checked }); + } + + render() { + const isSystem = this.props.userServiceAvailable && this.props.systemServiceAvailable; + const userVolumes = this.props.unusedVolumes.filter(volume => !volume.isSystem); + const systemVolumes = this.props.unusedVolumes.filter(volume => volume.isSystem); + const showCheckboxes = userVolumes.length > 0 && systemVolumes.length > 0; + return ( + + + + } + > + + {isSystem && + } + + + + ); + } +} + +export default PruneUnusedVolumesModal; diff --git a/src/VolumeCreateModal.jsx b/src/VolumeCreateModal.jsx new file mode 100644 index 000000000..45529199a --- /dev/null +++ b/src/VolumeCreateModal.jsx @@ -0,0 +1,246 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + EmptyState, EmptyStateBody, + Form, FormGroup, FormFieldGroup, FormFieldGroupHeader, + HelperText, HelperTextItem, + Modal, Radio, + TextInput, Tabs, Tab, TabTitleText +} from '@patternfly/react-core'; +import * as dockerNames from 'docker-names'; + +import { ErrorNotification } from './Notification.jsx'; +import * as client from './client.js'; +import cockpit from 'cockpit'; + +import "./VolumeCreateModal.scss"; + +const _ = cockpit.gettext; + +const systemOwner = "system"; + +class DynamicListForm extends React.Component { + constructor(props) { + super(props); + this.state = { + list: [], + }; + this.keyCounter = 0; + this.removeItem = this.removeItem.bind(this); + this.addItem = this.addItem.bind(this); + this.onItemChange = this.onItemChange.bind(this); + } + + removeItem(idx, field, value) { + this.setState(state => { + const items = state.list.concat(); + items.splice(idx, 1); + return { list: items }; + }, () => this.props.onChange(this.state.list.concat())); + } + + addItem() { + this.setState(state => { + return { list: [...state.list, Object.assign({ key: this.keyCounter++ }, this.props.default)] }; + }, () => this.props.onChange(this.state.list.concat())); + } + + onItemChange(idx, field, value) { + this.setState(state => { + const items = state.list.concat(); + items[idx][field] = value || null; + return { list: items }; + }, () => this.props.onChange(this.state.list.concat())); + } + + render () { + const { id, label, actionLabel, formclass, emptyStateString, helperText } = this.props; + const dialogValues = this.state; + return ( + {actionLabel}} + /> + } className={"dynamic-form-group " + formclass}> + { + dialogValues.list.length + ? <> + {dialogValues.list.map((item, idx) => { + return React.cloneElement(this.props.itemcomponent, { + idx: idx, item: item, id: id + "-" + idx, + key: idx, + onChange: this.onItemChange, removeitem: this.removeItem, additem: this.addItem, options: this.props.options, + itemCount: Object.keys(dialogValues.list).length, + }); + }) + } + {helperText && + + {helperText} + + } + + : + + {emptyStateString} + + + } + + ); + } +} +DynamicListForm.propTypes = { + emptyStateString: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + id: PropTypes.string.isRequired, + itemcomponent: PropTypes.object.isRequired, + formclass: PropTypes.string, + options: PropTypes.object, +}; + +export class VolumeCreateModal extends React.Component { + constructor(props) { + super(props); + + this.state = { + volumeName: dockerNames.getRandomName(), + owner: this.props.systemServiceAvailable ? systemOwner : this.props.user, + }; + this.getCreateConfig = this.getCreateConfig.bind(this); + this.onValueChanged = this.onValueChanged.bind(this); + } + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + + if (this.activeConnection) + this.activeConnection.close(); + } + + getCreateConfig() { + const createConfig = {}; + + if (this.state.volumeName) { + createConfig.Name = this.state.volumeName; + } + + return createConfig; + } + + createVolume = (isSystem, createConfig) => { + client.createVolume(isSystem, createConfig) + .then(reply => { + this.props.close(); + }) + .catch(ex => { + this.setState({ + dialogError: _("Volume failed to be created"), + dialogErrorDetail: cockpit.format("$0: $1", ex.reason, ex.message) + }); + }); + } + + async onCreateClicked() { + const createConfig = this.getCreateConfig(); + const isSystem = this.isSystem(); + + this.createVolume(isSystem, createConfig); + } + + onValueChanged(key, value) { + this.setState({ [key]: value }); + } + + handleTabClick = (event, tabIndex) => { + // Prevent the form from being submitted. + event.preventDefault(); + this.setState({ + activeTabKey: tabIndex, + }); + }; + + handleOwnerSelect = (_, event) => { + const value = event.currentTarget.value; + this.setState({ + owner: value + }); + } + + enablePodmanRestartService = () => { + const argv = ["systemctl", "enable", "podman-restart.service"]; + + cockpit.spawn(argv, { superuser: "require", err: "message" }) + .catch(err => { + console.warn("Failed to start podman-restart.service:", JSON.stringify(err)); + }); + } + + isSystem = () => { + const { owner } = this.state; + return owner === systemOwner; + } + + render() { + const dialogValues = this.state; + const { activeTabKey, owner } = this.state; + + const defaultBody = ( +
+ + this.onValueChanged('volumeName', value)} /> + + + {_("Details")}} className="pf-c-form pf-m-horizontal"> + { this.props.userServiceAvailable && this.props.systemServiceAvailable && + + + + + } + + + +
+ ); + return ( + { + this.props.close(); + }} + title={_("Create container")} + footer={<> + {this.state.dialogError && } + + + } + > + {defaultBody} + + ); + } +} diff --git a/src/VolumeCreateModal.scss b/src/VolumeCreateModal.scss new file mode 100644 index 000000000..ccde94242 --- /dev/null +++ b/src/VolumeCreateModal.scss @@ -0,0 +1,79 @@ +@import "global-variables"; + +.dynamic-form-group { + .pf-c-empty-state { + padding: 0; + } + + .pf-c-form__label { + // Don't allow labels to wrap + white-space: nowrap + } + + .remove-button-group { + // Move 'Remove' button the the end of the row + grid-column: -1; + // Move 'Remove' button to the bottom of the line so as to align with the other form fields + display: flex; + align-items: flex-end; + } + + // Set check to the same height as input widgets and vertically align + .pf-c-form__group-control > .pf-c-check { + // Set height to the same as inputs + // Font height is font size * line height (1rem * 1.5) + // Widgets have 5px padding, 1px border (top & bottom): (5 + 1) * 2 = 12 + // This all equals to 36px + height: calc(var(--pf-global--FontSize--md) * var(--pf-global--LineHeight--md) + 12px); + align-content: center; + } +} + +// Ensure the width fits within the screen boundaries (with padding on the sides) +.pf-c-select__menu { + // 3xl is the left+right padding for an iPhone SE; + // this works on other screen sizes as well + max-width: calc(100vw - var(--pf-global--spacer--3xl)); +} + +// Make sure the footer is visible with more then 5 results. +.pf-c-select__menu-list { + // 35% viewport height is for 1280x720; + // since it picks the min of the two, it works everywhere + max-height: min(20rem, 35vh); + overflow: hidden scroll; +} + +// Fix the dot next to spinner: https://github.com/patternfly/patternfly-react/issues/6383 +.pf-c-select__list-item.pf-m-loading { + list-style-type: none +} + +.image-search-footer { + flex-wrap: wrap; + .pf-c-toggle-group__text { + word-wrap: break-word; + } +} + + // PF4 does not yet support multiple form fields for the same label +.ct-input-group-spacer-sm.pf-l-flex { + // Limit width for select entries and inputs in the input groups otherwise they take up the whole space + > .pf-c-select, .pf-c-form-control:not(.pf-c-select__toggle-typeahead) { + max-width: 8ch; + } +} + +.run-image-dialog-restart-retries { + max-width: 5ch; +} + +// HACK: A local copy of pf-m-horizontal (as ct-m-horizontal), +// but applied at the FormGroup level instead of Form +@media (min-width: $pf-global--breakpoint--md) { + .pf-c-form__group.ct-m-horizontal { + display: grid; + grid-column-gap: var(--pf-c-form--m-horizontal__group-label--md--GridColumnGap); + grid-template-columns: var(--pf-c-form--m-horizontal__group-label--md--GridColumnWidth) var(--pf-c-form--m-horizontal__group-control--md--GridColumnWidth); + } +} diff --git a/src/VolumeDeleteModal.jsx b/src/VolumeDeleteModal.jsx new file mode 100644 index 000000000..6367ddfba --- /dev/null +++ b/src/VolumeDeleteModal.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Button, Modal } from '@patternfly/react-core'; +import cockpit from 'cockpit'; + +const _ = cockpit.gettext; + +export class VolumeDeleteModal extends React.Component { + render() { + return ( + + + + } + /> + ); + } +} diff --git a/src/VolumeDetails.jsx b/src/VolumeDetails.jsx new file mode 100644 index 000000000..152088d97 --- /dev/null +++ b/src/VolumeDetails.jsx @@ -0,0 +1,58 @@ +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.keys(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 && render_map(volume.Options); + + return ( + + {volume.Labels.length && + + {_("Labels")} + {labels} + + } + {volume.Scope && + + {_("Scope")} + {volume.Scope} + + } + {volume.Options.length && + + {_("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..6f5d8c6d1 --- /dev/null +++ b/src/Volumes.css @@ -0,0 +1,18 @@ +#containers-volumes div.download-in-progress { + color: grey; + font-weight: bold; +} + +/* 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-expandable-section__content { + margin-top: 0px; +} diff --git a/src/Volumes.jsx b/src/Volumes.jsx new file mode 100644 index 000000000..55c12f2dd --- /dev/null +++ b/src/Volumes.jsx @@ -0,0 +1,307 @@ +import React, { useState } from 'react'; +import { + Card, CardBody, CardHeader, + Dropdown, DropdownItem, + Flex, FlexItem, + ExpandableSection, + KebabToggle, + Text, TextVariants, + ToolbarItem, Button +} from '@patternfly/react-core'; +import { cellWidth } from '@patternfly/react-table'; + +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 }, + { title: { volume.Mountpoint } }, + { title: volume.isSystem ? _("system") :
{_("user:")} {this.props.user}
, props: { modifier: "nowrap" } }, + utils.localize_date(volume.CreatedAt), + { 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"), + { title: _("Mountpoint"), transforms: [cellWidth(20)] }, + _("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; + }); + } + + 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 26183f1e1..8b86d2df2 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'; const _ = cockpit.gettext; @@ -50,6 +51,9 @@ class Application extends React.Component { containersFilter: "running", containersStats: {}, containersDetails: {}, + volumes: null, + userVolumesLoaded: false, + systemVolumesLoaded: false, userContainersLoaded: null, systemContainersLoaded: null, userPodsLoaded: null, @@ -72,11 +76,13 @@ class Application extends React.Component { this.onFilterChanged = this.onFilterChanged.bind(this); this.onOwnerChanged = this.onOwnerChanged.bind(this); this.updateImagesAfterEvent = this.updateImagesAfterEvent.bind(this); + this.updateVolumesAfterEvent = this.updateVolumesAfterEvent.bind(this); this.updateContainerAfterEvent = this.updateContainerAfterEvent.bind(this); this.updateContainerStats = this.updateContainerStats.bind(this); this.startService = this.startService.bind(this); this.goToServicePage = this.goToServicePage.bind(this); this.handleImageEvent = this.handleImageEvent.bind(this); + this.handleVolumeEvent = this.handleVolumeEvent.bind(this); this.handleContainerEvent = this.handleContainerEvent.bind(this); this.checkUserService = this.checkUserService.bind(this); } @@ -190,11 +196,9 @@ class Application extends React.Component { [system ? "systemContainersLoaded" : "userContainersLoaded"]: true, }; }); - if (init) { - this.updateContainerStats(system); - for (const container of reply || []) { - this.inspectContainerDetail(container.Id, system); - } + this.updateContainerStats(system); + for (const container of reply || []) { + this.inspectContainerDetail(container.Id, system); } }) .catch(console.log); @@ -227,6 +231,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)); + }); + } + updatePodsAfterEvent(system) { client.getPods(system) .then(reply => { @@ -334,6 +365,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) { switch (event.Action) { /* The following events do not need to trigger any state updates */ @@ -408,6 +451,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; @@ -417,7 +463,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 = {}; @@ -440,6 +486,7 @@ class Application extends React.Component { cgroupVersion: reply.host.cgroupVersion, }); this.updateImagesAfterEvent(system); + this.updateVolumesAfterEvent(system); this.updateContainersAfterEvent(system, true); this.updatePodsAfterEvent(system); client.streamEvents(system, @@ -468,6 +515,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 }); }); @@ -485,6 +533,7 @@ class Application extends React.Component { } else { this.setState({ userImagesLoaded: true, + userVolumesLoaded: true, userContainersLoaded: true, userPodsLoaded: true, userServiceExists: false @@ -542,7 +591,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)); }); @@ -559,7 +609,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)); }); @@ -620,6 +671,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 = (<> {_("Start")} @@ -652,11 +720,28 @@ class Application extends React.Component { selinuxAvailable={this.state.selinuxAvailable} podmanRestartAvailable={this.state.podmanRestartAvailable} />; + 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} {containerList} + {volumeList} + {imageList} diff --git a/src/client.js b/src/client.js index 4494b2327..d234e70dc 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) @@ -285,3 +285,54 @@ export function pruneUnusedImages(system) { export function imageExists(system, id) { return 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); + }); +} diff --git a/src/podman.scss b/src/podman.scss index 626d7eac5..6f79150d8 100644 --- a/src/podman.scss +++ b/src/podman.scss @@ -15,6 +15,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-c-table [data-label] > .pf-c-badge { @@ -61,13 +68,19 @@ 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-global--Color--200); } // Add borders to no pod containers list and images list .container-section.pf-m-plain tbody, -.containers-images tbody { +.containers-images tbody, +.containers-volumes tbody { border: var(--pf-c-card--m-flat--BorderWidth) solid var(--pf-c-card--m-flat--BorderColor); } @@ -76,7 +89,7 @@ white-space: nowrap !important; } -@media ( max-width: $pf-global--breakpoint--md - 1 ) { +@media ( max-width: $pf-global--breakpoint--md ) { .show-only-when-wide { display: none; } diff --git a/src/util.js b/src/util.js index ab83b399c..43bd0ce9c 100644 --- a/src/util.js +++ b/src/util.js @@ -24,6 +24,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 ""; From 40080ac7a9258271c28084f63c3e6eb86dcf2034 Mon Sep 17 00:00:00 2001 From: Pavel Strzinek Date: Tue, 12 Apr 2022 19:44:46 +0200 Subject: [PATCH 2/8] Fix volume layout and UI deficiencies --- src/VolumeCreateModal.jsx | 39 +++++++++++++++++---------------------- src/VolumeDeleteModal.jsx | 2 +- src/Volumes.css | 5 ----- src/Volumes.jsx | 9 ++++----- src/app.jsx | 4 ++-- 5 files changed, 24 insertions(+), 35 deletions(-) diff --git a/src/VolumeCreateModal.jsx b/src/VolumeCreateModal.jsx index 45529199a..cf84343b1 100644 --- a/src/VolumeCreateModal.jsx +++ b/src/VolumeCreateModal.jsx @@ -6,7 +6,7 @@ import { Form, FormGroup, FormFieldGroup, FormFieldGroupHeader, HelperText, HelperTextItem, Modal, Radio, - TextInput, Tabs, Tab, TabTitleText + TextInput } from '@patternfly/react-core'; import * as dockerNames from 'docker-names'; @@ -189,7 +189,7 @@ export class VolumeCreateModal extends React.Component { render() { const dialogValues = this.state; - const { activeTabKey, owner } = this.state; + const { owner } = this.state; const defaultBody = (
@@ -200,25 +200,20 @@ export class VolumeCreateModal extends React.Component { value={dialogValues.volumeName} onChange={value => this.onValueChanged('volumeName', value)} /> - - {_("Details")}} className="pf-c-form pf-m-horizontal"> - { this.props.userServiceAvailable && this.props.systemServiceAvailable && - - - - - } - - - + { this.props.userServiceAvailable && this.props.systemServiceAvailable && + + + + + }
); return ( @@ -228,7 +223,7 @@ export class VolumeCreateModal extends React.Component { onEscapePress={() => { this.props.close(); }} - title={_("Create container")} + title={_("Create volume")} footer={<> {this.state.dialogError && } } diff --git a/src/Volumes.css b/src/Volumes.css index 6f5d8c6d1..89ac43174 100644 --- a/src/Volumes.css +++ b/src/Volumes.css @@ -1,8 +1,3 @@ -#containers-volumes div.download-in-progress { - color: grey; - font-weight: bold; -} - /* Danger dropdown items should be red */ .pf-c-dropdown__menu-item.pf-m-danger { color: var(--pf-global--danger-color--200); diff --git a/src/Volumes.jsx b/src/Volumes.jsx index 55c12f2dd..2b3b35518 100644 --- a/src/Volumes.jsx +++ b/src/Volumes.jsx @@ -8,7 +8,6 @@ import { Text, TextVariants, ToolbarItem, Button } from '@patternfly/react-core'; -import { cellWidth } from '@patternfly/react-table'; import cockpit from 'cockpit'; import { ListingTable } from "cockpit-components-table.jsx"; @@ -73,10 +72,10 @@ class Volumes extends React.Component { const tabs = []; const columns = [ - { title: volume.Name }, - { title: { volume.Mountpoint } }, + { 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" } }, - utils.localize_date(volume.CreatedAt), + { title: utils.localize_date(volume.CreatedAt), props: { modifier: "nowrap" } }, { title: volume.Driver, props: { modifier: "nowrap" } }, { title: { this.state.showStartService ? startService : null } - {containerList} - {volumeList} {imageList} + {volumeList} + {containerList} From 6ab1d8b6a951736a263846da4e461f5cff4397ae Mon Sep 17 00:00:00 2001 From: strzinek Date: Thu, 14 Apr 2022 22:29:48 +0200 Subject: [PATCH 3/8] reduce panel height when no volumes present --- src/Volumes.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Volumes.css b/src/Volumes.css index 89ac43174..4c5b61516 100644 --- a/src/Volumes.css +++ b/src/Volumes.css @@ -8,6 +8,10 @@ --pf-c-table__action--PaddingBottom: 0.5rem; } -.containers-volumes .pf-c-expandable-section__content { +.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 From 343efea33906a2f14e003db2ab3507fb6f441f61 Mon Sep 17 00:00:00 2001 From: strzinek Date: Sat, 16 Apr 2022 15:06:38 +0200 Subject: [PATCH 4/8] fix volumes filtering --- src/Volumes.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Volumes.jsx b/src/Volumes.jsx index 2b3b35518..45d4acabb 100644 --- a/src/Volumes.jsx +++ b/src/Volumes.jsx @@ -135,6 +135,11 @@ class Volumes extends React.Component { }); } + 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) From e79a555bddce15b48e4f04941d4d52ed3c6eecf4 Mon Sep 17 00:00:00 2001 From: strzinek Date: Sun, 17 Apr 2022 16:26:35 +0200 Subject: [PATCH 5/8] Distinguish bw mount types in container details --- src/ContainerDetails.jsx | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/ContainerDetails.jsx b/src/ContainerDetails.jsx index eda25e1b7..0d55c43fd 100644 --- a/src/ContainerDetails.jsx +++ b/src/ContainerDetails.jsx @@ -43,13 +43,23 @@ const render_container_mounts = (mounts) => { const driver = mount.Driver; const source = mount.Source; const destination = mount.Destination; - return ( - - { name } → { destination }
- { driver } { type }:
- { source }
-
- ); + const RW = mount.RW; + + if (type == "local") + return ( + + { name } → { destination }
+ { driver } { type } { RW ? "RW" : "RO" }:
+ { source }
+
+ ); + else + return ( + + { source } → { destination }
+ { type } { RW ? "RW" : "RO" } +
+ ); }); return {result}; From 13a1c50fae2a879d7a0bba3eeef8b7db12bb601b Mon Sep 17 00:00:00 2001 From: strzinek Date: Sun, 17 Apr 2022 16:33:47 +0200 Subject: [PATCH 6/8] fix: volume type mount in container details --- src/ContainerDetails.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ContainerDetails.jsx b/src/ContainerDetails.jsx index 0d55c43fd..417456c62 100644 --- a/src/ContainerDetails.jsx +++ b/src/ContainerDetails.jsx @@ -45,7 +45,7 @@ const render_container_mounts = (mounts) => { const destination = mount.Destination; const RW = mount.RW; - if (type == "local") + if (type == "volume") return ( { name } → { destination }
From 6357d43d487858b514202e2ae264587a87861974 Mon Sep 17 00:00:00 2001 From: strzinek Date: Mon, 18 Apr 2022 22:32:55 +0200 Subject: [PATCH 7/8] remove scope and fix labels in volume details --- src/VolumeDetails.jsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/VolumeDetails.jsx b/src/VolumeDetails.jsx index 152088d97..efe5d098b 100644 --- a/src/VolumeDetails.jsx +++ b/src/VolumeDetails.jsx @@ -10,7 +10,7 @@ const render_map = (labels) => { if (!labels) return null; - const result = Object.keys(labels).map((key, value) => { + const result = Object.entries(labels).map(([key, value]) => { return ( { key }: { value } @@ -23,25 +23,19 @@ const render_map = (labels) => { const VolumeDetails = ({ volume, containers, showAll }) => { const labels = volume.Labels && render_map(volume.Labels); - const options = volume.Options && render_map(volume.Options); + const options = volume.Options && Object.keys(volume.Options || {}).join(', '); return ( - {volume.Labels.length && + {Object.entries(volume.Labels).length !== 0 && {_("Labels")} {labels} } - {volume.Scope && + {options && - {_("Scope")} - {volume.Scope} - - } - {volume.Options.length && - - {_("Options")} + {_("Mount options")} {options} } From 080d6a8e2342fed5c82672874cd740970602b3de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Str=C5=BE=C3=ADnek?= <35457537+strzinek@users.noreply.github.com> Date: Sat, 17 Feb 2024 20:02:36 +0100 Subject: [PATCH 8/8] remove unused property --- src/VolumeCreateModal.jsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/VolumeCreateModal.jsx b/src/VolumeCreateModal.jsx index cf84343b1..a1faed6c6 100644 --- a/src/VolumeCreateModal.jsx +++ b/src/VolumeCreateModal.jsx @@ -161,9 +161,6 @@ export class VolumeCreateModal extends React.Component { handleTabClick = (event, tabIndex) => { // Prevent the form from being submitted. event.preventDefault(); - this.setState({ - activeTabKey: tabIndex, - }); }; handleOwnerSelect = (_, event) => {