Skip to content

Commit 39d1de1

Browse files
committed
List volume support
1 parent 7276997 commit 39d1de1

4 files changed

Lines changed: 256 additions & 3 deletions

File tree

src/VolumeDeleteModal.jsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* SPDX-License-Identifier: LGPL-2.1-or-later */
2+
import React, { useState } from 'react';
3+
4+
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
5+
import {
6+
Modal, ModalBody, ModalFooter, ModalHeader
7+
} from '@patternfly/react-core/dist/esm/components/Modal';
8+
import { useDialogs } from "dialogs.jsx";
9+
10+
import cockpit from 'cockpit';
11+
12+
import * as client from './client.js';
13+
14+
const _ = cockpit.gettext;
15+
16+
export const VolumeDeleteModal = ({ con, volume }) => {
17+
const [force, setForce] = useState(false);
18+
const [reason, setReason] = useState(null);
19+
const Dialogs = useDialogs();
20+
21+
const handleRemoveVolume = async () => {
22+
setReason(null);
23+
try {
24+
await client.deleteVolume(con, volume.Name, force);
25+
Dialogs.close();
26+
} catch (exc) {
27+
setReason(exc.message);
28+
setForce(true);
29+
}
30+
};
31+
32+
return (
33+
<Modal isOpen
34+
position="top" variant="medium"
35+
onClose={Dialogs.close}
36+
>
37+
<ModalHeader title={cockpit.format(_("Delete $0 volume?"), volume.Name)}
38+
titleIconVariant="warning"
39+
/>
40+
<ModalBody>
41+
{reason}
42+
</ModalBody>
43+
<ModalFooter>
44+
<Button id="btn-volme-delete" variant="danger"
45+
onClick={() => handleRemoveVolume()}>
46+
{force ? _("Force delete volume") : _("Delete volume")}
47+
</Button>
48+
<Button variant="link" onClick={Dialogs.close}>{_("Cancel")}</Button>
49+
</ModalFooter>
50+
</Modal>
51+
);
52+
};

src/Volumes.jsx

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/* SPDX-License-Identifier: LGPL-2.1-or-later */
2+
import React from 'react';
3+
4+
import { Card, CardBody, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card";
5+
import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
6+
import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection";
7+
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
8+
import { cellWidth, SortByDirection } from '@patternfly/react-table';
9+
import { KebabDropdown } from "cockpit-components-dropdown.jsx";
10+
import { ListingTable } from "cockpit-components-table.jsx";
11+
import { useDialogs } from 'dialogs.js';
12+
13+
import cockpit from 'cockpit';
14+
15+
import { VolumeDeleteModal } from './VolumeDeleteModal.jsx';
16+
import * as utils from './util.js';
17+
18+
const _ = cockpit.gettext;
19+
20+
const Volumes = ({ users, volumes, ownerFilter, textFilter, volumeContainerMap }) => {
21+
const [isExpanded, setIsExpanded] = React.useState(false);
22+
23+
const getUsedByText = (volume) => {
24+
if (volumeContainerMap === null) {
25+
return { title: _("unused"), count: 0 };
26+
}
27+
const containers = volumeContainerMap[volume.key];
28+
if (containers !== undefined) {
29+
const title = cockpit.format(cockpit.ngettext("$0 container", "$0 containers", containers.length), containers.length);
30+
return { title, count: containers.length };
31+
} else {
32+
return { title: _("unused"), count: 0 };
33+
}
34+
};
35+
36+
const renderRow = volume => {
37+
const { title: usedByText, count: usedByCount } = getUsedByText(volume);
38+
const user = users.find(user => user.uid === volume.uid);
39+
cockpit.assert(user, `User not found for volume uid ${volume.uid}`);
40+
41+
const columns = [
42+
{ title: volume.Name, header: true, props: { modifier: "breakWord" } },
43+
{
44+
title: (volume.uid === 0) ? _("system") : <div><span className="ct-grey-text">{_("user:")} </span>{user.name}</div>,
45+
props: { modifier: "nowrap" },
46+
sortKey: volume.key,
47+
},
48+
{ title: volume.Mountpoint, header: true, props: { modifier: "breakWord" } },
49+
{ title: volume.Driver, header: true, props: { modifier: "breakWord" } },
50+
{ title: <utils.RelativeTime time={volume.CreatedAt} />, props: { className: "ignore-pixels" } },
51+
{ title: <span className={usedByCount === 0 ? "ct-grey-text" : ""}>{usedByText}</span>, props: { className: "ignore-pixels", modifier: "nowrap" }, sortKey: usedByCount },
52+
{
53+
title: <VolumeActions con={user.con} volume={volume} />,
54+
props: { className: 'pf-v6-c-table__action content-action' }
55+
},
56+
];
57+
58+
return {
59+
columns,
60+
props: {
61+
key: volume.key,
62+
"data-row-id": volume.key,
63+
"data-row-name": `${volume.uid === null ? 'user' : volume.uid}-${volume.Name}`
64+
},
65+
};
66+
};
67+
68+
const sortRows = (rows, direction, idx) => {
69+
// Name / Owner / Mount Point / Driver / Created / Used by
70+
const isNumeric = idx == 4 || idx == 5;
71+
const sortedRows = rows.sort((a, b) => {
72+
const aitem = a.columns[idx].sortKey ?? a.columns[idx].title;
73+
const bitem = b.columns[idx].sortKey ?? b.columns[idx].title;
74+
if (isNumeric) {
75+
return bitem - aitem;
76+
} else {
77+
return aitem.localeCompare(bitem);
78+
}
79+
});
80+
return direction === SortByDirection.asc ? sortedRows : sortedRows.reverse();
81+
};
82+
83+
const columnTitles = [
84+
{ title: _("Name"), transforms: [cellWidth(20)], sortable: true },
85+
{ title: _("Owner"), sortable: true },
86+
{ title: _("Mount point"), sortable: true },
87+
{ title: _("Driver"), sortable: true },
88+
{ title: _("Created"), sortable: true },
89+
{ title: _("Used by"), sortable: true },
90+
];
91+
92+
let emptyCaption = _("No volumes");
93+
if (volumes === null) {
94+
emptyCaption = _("Loading...");
95+
} else if (textFilter.length > 0) {
96+
emptyCaption = _("No volumes that match the current filter");
97+
}
98+
99+
const volumeKeys = Object.keys(volumes || {});
100+
const volumesTotal = volumeKeys.length;
101+
102+
let filtered = [];
103+
if (volumes !== null) {
104+
filtered = volumeKeys.filter(id => {
105+
if (ownerFilter !== "all") {
106+
if (ownerFilter === "user")
107+
return volumes[id].uid === null;
108+
return volumes[id].uid === ownerFilter;
109+
}
110+
111+
const name = volumes[id].Name;
112+
if (textFilter.length > 0)
113+
return name.toLowerCase().includes(textFilter);
114+
return true;
115+
});
116+
}
117+
118+
const rows = filtered.map(name => renderRow(volumes[name]));
119+
120+
const cardBody = (
121+
<ListingTable variant='compact'
122+
aria-label={_("Volumes")}
123+
emptyCaption={emptyCaption}
124+
columns={columnTitles}
125+
rows={rows}
126+
sortMethod={sortRows}
127+
/>
128+
);
129+
130+
const volumesTitleStats = (
131+
<h5>
132+
{cockpit.format(cockpit.ngettext("$0 volume total", "$0 volumes total", volumesTotal), volumesTotal)}
133+
</h5>
134+
);
135+
136+
return (
137+
<Card id="containers-volumes" className="containers-volumes">
138+
<CardHeader>
139+
<Flex flexWrap={{ default: 'nowrap' }} className="pf-v6-u-w-100">
140+
<FlexItem grow={{ default: 'grow' }}>
141+
<Flex>
142+
<CardTitle>
143+
<h2 className="containers-images-title">{_("Volumes")}</h2>
144+
</CardTitle>
145+
<Flex className="ignore-pixels" style={{ rowGap: "var(--pf-v6-global--spacer--xs)" }}>{volumesTitleStats}</Flex>
146+
</Flex>
147+
</FlexItem>
148+
</Flex>
149+
</CardHeader>
150+
<CardBody>
151+
{volumes && Object.keys(volumes).length
152+
? <ExpandableSection toggleText={isExpanded ? _("Hide volumes") : _("Show volumes")}
153+
onToggle={() => setIsExpanded(!isExpanded)}
154+
isExpanded={isExpanded}>
155+
{cardBody}
156+
</ExpandableSection>
157+
: cardBody}
158+
</CardBody>
159+
</Card>
160+
);
161+
};
162+
163+
const VolumeActions = ({ con, volume }) => {
164+
const Dialogs = useDialogs();
165+
166+
const removeImage = () => {
167+
Dialogs.show(<VolumeDeleteModal
168+
con={con}
169+
volume={volume}
170+
171+
/>);
172+
};
173+
174+
const dropdownActions = [
175+
<DropdownItem key={volume.Name + "delete"}
176+
component="button"
177+
className="pf-m-danger btn-delete"
178+
onClick={removeImage}>
179+
{_("Delete")}
180+
</DropdownItem>
181+
];
182+
183+
return <KebabDropdown position="right" dropdownItems={dropdownActions} />;
184+
};
185+
186+
export default Volumes;

src/app.jsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,6 @@ class Application extends React.Component {
201201
initVolumes(con) {
202202
return client.getVolumes(con)
203203
.then(volumesList => {
204-
console.log(volumesList);
205204
this.setState(prevState => {
206205
const copyVolumes = {};
207206
Object.entries(prevState.volumes || {}).forEach(([id, volume]) => {
@@ -445,7 +444,6 @@ class Application extends React.Component {
445444
}
446445

447446
handleVolumeEvent(event, con) {
448-
console.log(event, con);
449447
switch (event.Action) {
450448
case 'create':
451449
this.initVolumes(con);
@@ -908,6 +906,7 @@ class Application extends React.Component {
908906
return null;
909907

910908
let imageContainerMap = {};
909+
let volumeContainerMap = {};
911910
if (this.state.containers !== null) {
912911
Object.keys(this.state.containers).forEach(c => {
913912
const container = this.state.containers[c];
@@ -918,9 +917,22 @@ class Application extends React.Component {
918917
container,
919918
stats: this.state.containersStats[makeKey(container.uid, container.Id)],
920919
});
920+
921+
for (const mount of (container.Mounts || [])) {
922+
if (mount.Type != "volume")
923+
continue;
924+
925+
const volumeKey = makeKey(container.uid, mount.Name);
926+
if (!volumeContainerMap[volumeKey])
927+
volumeContainerMap[volumeKey] = [];
928+
929+
volumeContainerMap[volumeKey].push(volumeKey);
930+
}
921931
});
922-
} else
932+
} else {
923933
imageContainerMap = null;
934+
volumeContainerMap = null;
935+
}
924936

925937
const loadingImages = this.state.users.find(u => u.con && !u.imagesLoaded);
926938
const loadingContainers = this.state.users.find(u => u.con && !u.containersLoaded);
@@ -968,6 +980,7 @@ class Application extends React.Component {
968980
textFilter={this.state.textFilter}
969981
ownerFilter={this.state.ownerFilter}
970982
volumes={loadingVolumes ? null : this.state.volumes}
983+
volumeContainerMap={volumeContainerMap}
971984
/>
972985
);
973986

src/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,5 @@ export const imageExists = (con: Connection, id: string) => podmanCall(con, "lib
163163
export const containerExists = (con: Connection, id: string) => podmanCall(con, "libpod/containers/" + id + "/exists", "GET", {});
164164

165165
export const getVolumes = (con: Connection) => podmanJson(con, "libpod/volumes/json", "GET", {});
166+
167+
export const deleteVolume = (con: Connection, name: string, force: boolean = false) => podmanCall(con, `libpod/volumes/${name}`, "DELETE", { force });

0 commit comments

Comments
 (0)