diff --git a/po/cs.po b/po/cs.po index 39662d306..453edcd90 100644 --- a/po/cs.po +++ b/po/cs.po @@ -1375,6 +1375,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" + #: src/Containers.jsx:585 msgid "volumes" msgstr "svazky" diff --git a/src/ContainerDetails.jsx b/src/ContainerDetails.jsx index c6af977a4..af44937d6 100644 --- a/src/ContainerDetails.jsx +++ b/src/ContainerDetails.jsx @@ -57,6 +57,22 @@ const ContainerDetails = ({ container }) => { } } + + + {mounts && + {_("Mounts")} + {mounts} + } + + + + + {env && + {_("ENV")} + {env} + } + + 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..a1faed6c6 --- /dev/null +++ b/src/VolumeCreateModal.jsx @@ -0,0 +1,238 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + EmptyState, EmptyStateBody, + Form, FormGroup, FormFieldGroup, FormFieldGroupHeader, + HelperText, HelperTextItem, + Modal, Radio, + TextInput +} 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(); + }; + + 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 { owner } = this.state; + + const defaultBody = ( +
+ + this.onValueChanged('volumeName', value)} /> + + { this.props.userServiceAvailable && this.props.systemServiceAvailable && + + + + + } +
+ ); + return ( + { + this.props.close(); + }} + title={_("Create volume")} + 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..1dd3a4d0c --- /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..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 "";