diff --git a/pkg/storaged/actions.jsx b/pkg/storaged/actions.jsx
new file mode 100644
index 000000000000..5b34b8c9e279
--- /dev/null
+++ b/pkg/storaged/actions.jsx
@@ -0,0 +1,74 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import client from "./client";
+
+import { get_existing_passphrase, unlock_with_type } from "./crypto-keyslots.jsx"; // XXX
+import { set_crypto_auto_option } from "./utils.js";
+import { dialog_open, PassInput } from "./dialog.jsx";
+
+const _ = cockpit.gettext;
+
+export function unlock(block) {
+ const crypto = client.blocks_crypto[block.path];
+ if (!crypto)
+ return;
+
+ function unlock_with_passphrase() {
+ const crypto = client.blocks_crypto[block.path];
+ if (!crypto)
+ return;
+
+ dialog_open({
+ Title: _("Unlock"),
+ Fields: [
+ PassInput("passphrase", _("Passphrase"), {})
+ ],
+ Action: {
+ Title: _("Unlock"),
+ action: function (vals) {
+ return (crypto.Unlock(vals.passphrase, {})
+ .then(() => set_crypto_auto_option(block, true)));
+ }
+ }
+ });
+ }
+
+ return get_existing_passphrase(block, true).then(type => {
+ return (unlock_with_type(client, block, null, type)
+ .then(() => set_crypto_auto_option(block, true))
+ .catch(() => unlock_with_passphrase()));
+ });
+}
+
+export function lock(block) {
+ const crypto = client.blocks_crypto[block.path];
+ if (!crypto)
+ return;
+
+ return crypto.Lock({}).then(() => set_crypto_auto_option(block, false));
+}
+
+export function std_lock_action(backing_block, content_block) {
+ if (backing_block == content_block)
+ return null;
+
+ return { title: _("Lock"), action: () => lock(backing_block) };
+}
diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js
index dc31ccc12153..6dd9f43ec9c9 100644
--- a/pkg/storaged/client.js
+++ b/pkg/storaged/client.js
@@ -35,6 +35,8 @@ import vdo_monitor_py from "./vdo-monitor.py";
import stratis2_set_key_py from "./stratis2-set-key.py";
import stratis3_set_key_py from "./stratis3-set-key.py";
+import { create_pages } from "./create-pages.jsx";
+
/* STORAGED CLIENT
*/
@@ -544,6 +546,25 @@ function update_indices() {
client.blocks_partitions[path].sort(function (a, b) { return a.Offset - b.Offset });
}
+ client.iscsi_sessions_drives = { };
+ client.drives_iscsi_session = { };
+ for (path in client.drives) {
+ const block = client.drives_block[path];
+ if (!block)
+ continue;
+ for (const session_path in client.iscsi_sessions) {
+ const session = client.iscsi_sessions[session_path];
+ for (i = 0; i < block.Symlinks.length; i++) {
+ if (utils.decode_filename(block.Symlinks[i]).includes(session.data.target_name)) {
+ client.drives_iscsi_session[path] = session;
+ if (!client.iscsi_sessions_drives[session_path])
+ client.iscsi_sessions_drives[session_path] = [];
+ client.iscsi_sessions_drives[session_path].push(client.drives[path]);
+ }
+ }
+ }
+ }
+
client.path_jobs = { };
function enter_job(job) {
if (!job.Objects || !job.Objects.length)
@@ -563,10 +584,15 @@ function update_indices() {
}
}
-client.update = () => {
- update_indices();
- client.path_warnings = find_warnings(client);
- client.dispatchEvent("changed");
+client.update = (first_time) => {
+ if (first_time)
+ client.ready = true;
+ if (client.ready) {
+ update_indices();
+ client.path_warnings = find_warnings(client);
+ create_pages();
+ client.dispatchEvent("changed");
+ }
};
function init_model(callback) {
@@ -705,7 +731,7 @@ function init_model(callback) {
client.storaged_client.addEventListener('notify', () => client.update());
- client.update();
+ client.update(true);
callback();
});
});
@@ -801,7 +827,7 @@ function nfs_mounts() {
if (lines.length >= 2) {
self.entries = JSON.parse(lines[lines.length - 2]);
self.fsys_sizes = { };
- client.dispatchEvent('changed');
+ client.update();
}
})
.catch(function (error) {
@@ -824,11 +850,11 @@ function nfs_mounts() {
.then(function (output) {
const data = JSON.parse(output);
self.fsys_sizes[path] = [(data[2] - data[1]) * data[0], data[2] * data[0]];
- client.dispatchEvent('changed');
+ client.update();
})
.catch(function () {
self.fsys_sizes[path] = [0, 0];
- client.dispatchEvent('changed');
+ client.update();
});
return null;
diff --git a/pkg/storaged/containers/encryption.jsx b/pkg/storaged/containers/encryption.jsx
new file mode 100644
index 000000000000..a449bdd0b3b7
--- /dev/null
+++ b/pkg/storaged/containers/encryption.jsx
@@ -0,0 +1,251 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { useObject, useEvent } from "hooks";
+import * as python from "python.js";
+import * as timeformat from "timeformat.js";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { dialog_open, TextInput, PassInput } from "../dialog.jsx";
+import { block_name, encode_filename, decode_filename, parse_options, unparse_options, extract_option, edit_crypto_config } from "../utils.js";
+import { new_container } from "../pages.jsx";
+import luksmeta_monitor_hack_py from "../luksmeta-monitor-hack.py";
+import { is_mounted } from "../fsys-tab.jsx"; // XXX
+import { StorageLink } from "../storage-controls.jsx";
+import { CryptoKeyslots } from "../crypto-keyslots.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_encryption_container(parent, block) {
+ return new_container({
+ parent,
+ type_format: _("$0 (encrypted)"), // XXX - icon?
+ component: EncryptionContainer,
+ props: { block },
+ });
+}
+
+function monitor_luks(block) {
+ const self = {
+ stop,
+
+ luks_version: null,
+ slots: null,
+ slot_error: null,
+ max_slots: null,
+ };
+
+ cockpit.event_target(self);
+
+ const dev = decode_filename(block.Device);
+ const channel = python.spawn(luksmeta_monitor_hack_py, [dev], { superuser: true });
+ let buf = "";
+
+ channel.stream(output => {
+ buf += output;
+ const lines = buf.split("\n");
+ buf = lines[lines.length - 1];
+ if (lines.length >= 2) {
+ const data = JSON.parse(lines[lines.length - 2]);
+ self.slots = data.slots;
+ self.luks_version = data.version;
+ self.max_slots = data.max_slots;
+ self.dispatchEvent("changed");
+ }
+ });
+
+ channel.catch(err => {
+ self.slots = [];
+ self.slot_error = err;
+ self.dispatchEvent("changed");
+ });
+
+ function stop() {
+ channel.close();
+ }
+
+ return self;
+}
+
+function parse_tag_mtime(tag) {
+ if (tag && tag.indexOf("1:") == 0) {
+ try {
+ const parts = tag.split("-")[1].split(".");
+ // s:ns → ms
+ const mtime = parseInt(parts[0]) * 1000 + parseInt(parts[1]) * 1e-6;
+ return cockpit.format(_("Last modified: $0"), timeformat.dateTime(mtime));
+ } catch {
+ return null;
+ }
+ } else
+ return null;
+}
+
+function monitor_mtime(path) {
+ const self = {
+ stop,
+
+ mtime: 0
+ };
+
+ cockpit.event_target(self);
+
+ let file = null;
+ if (path) {
+ file = cockpit.file(path, { superuser: true });
+ file.watch((_, tag) => { self.mtime = parse_tag_mtime(tag); self.dispatchEvent("changed") },
+ { read: false });
+ }
+
+ function stop() {
+ if (file)
+ file.close();
+ }
+
+ return self;
+}
+
+const EncryptionContainer = ({ container, block }) => {
+ const luks_info = useObject(() => monitor_luks(block),
+ m => m.stop(),
+ [block]);
+ useEvent(luks_info, "changed");
+
+ let old_options, passphrase_path;
+ const old_config = block.Configuration.find(c => c[0] == "crypttab");
+ if (old_config) {
+ old_options = (decode_filename(old_config[1].options.v)
+ .split(",")
+ .filter(function (s) { return s.indexOf("x-parent") !== 0 })
+ .join(","));
+ passphrase_path = decode_filename(old_config[1]["passphrase-path"].v);
+ }
+
+ const stored_passphrase_info = useObject(() => monitor_mtime(passphrase_path),
+ m => m.stop(),
+ [passphrase_path]);
+ useEvent(stored_passphrase_info, "changed");
+
+ const split_options = parse_options(old_options);
+ let opt_noauto = extract_option(split_options, "noauto");
+ const extra_options = unparse_options(split_options);
+
+ function edit_stored_passphrase() {
+ edit_crypto_config(block, function (config, commit) {
+ dialog_open({
+ Title: _("Stored passphrase"),
+ Fields: [
+ PassInput("passphrase", _("Stored passphrase"),
+ {
+ value: (config && config['passphrase-contents']
+ ? decode_filename(config['passphrase-contents'].v)
+ : "")
+ })
+ ],
+ Action: {
+ Title: _("Save"),
+ action: function (vals) {
+ config["passphrase-contents"] = {
+ t: 'ay',
+ v: encode_filename(vals.passphrase)
+ };
+ delete config["passphrase-path"];
+ return commit();
+ }
+ }
+ });
+ });
+ }
+
+ function edit_options() {
+ const fsys_config = client.blocks_crypto[block.path]?.ChildConfiguration.find(c => c[0] == "fstab");
+ const content_block = client.blocks_cleartext[block.path];
+ const is_fsys = fsys_config || (content_block && content_block.IdUsage == "filesystem");
+
+ edit_crypto_config(block, function (config, commit) {
+ dialog_open({
+ Title: _("Encryption options"),
+ Fields: [
+ TextInput("options", "", { value: extra_options }),
+ ],
+ isFormHorizontal: false,
+ Action: {
+ Title: _("Save"),
+ action: function (vals) {
+ let opts = [];
+ if (is_fsys && content_block)
+ opt_noauto = !is_mounted(client, content_block);
+ if (opt_noauto)
+ opts.push("noauto");
+ opts = opts.concat(parse_options(vals.options));
+ config.options = {
+ t: 'ay',
+ v: encode_filename(unparse_options(opts))
+ };
+ return commit();
+ }
+ }
+ });
+ });
+ }
+
+ const cleartext = client.blocks_cleartext[block.path];
+
+ const option_parts = [];
+ if (extra_options)
+ option_parts.push(extra_options);
+ const options = option_parts.join(", ");
+
+ return (
+
+
+
+
+ { luks_info.luks_version ? "LUKS" + luks_info.luks_version : "-" }
+
+
+ {cleartext ? block_name(cleartext) : "-"}
+
+
+
+ { passphrase_path ? stored_passphrase_info.mtime || _("yes") : _("none") }
+ {_("edit")}
+
+
+
+
+ { options || _("none") }
+ {_("edit")}
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/containers/lvm2-logical-volume.jsx b/pkg/storaged/containers/lvm2-logical-volume.jsx
new file mode 100644
index 000000000000..7cda68b6066c
--- /dev/null
+++ b/pkg/storaged/containers/lvm2-logical-volume.jsx
@@ -0,0 +1,221 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+import { StorageButton, StorageLink } from "../storage-controls.jsx";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { check_unused_space, get_resize_info, grow_dialog, shrink_dialog } from "../resize.jsx";
+import { new_container, navigate_to_new_page_location, ActionButtons } from "../pages.jsx";
+import { fmt_size } from "../utils.js";
+import { lvm2_delete_logical_volume_dialog, lvm2_create_snapshot_action } from "../pages/lvm2-volume-group.jsx";
+import {
+ dialog_open, TextInput, SelectSpaces,
+} from "../dialog.jsx";
+
+import { StructureDescription } from "../lvol-tabs.jsx"; // XXX
+import { pvs_to_spaces } from "../content-views.jsx"; // XXX
+
+const _ = cockpit.gettext;
+
+function repair(lvol) {
+ const vgroup = lvol && client.vgroups[lvol.VolumeGroup];
+ if (!vgroup)
+ return;
+
+ const summary = client.lvols_stripe_summary[lvol.path];
+ const missing = summary.reduce((sum, sub) => sum + (sub["/"] ?? 0), 0);
+
+ function usable(pvol) {
+ // must have some free space and not already used for a
+ // subvolume other than those that need to be repaired.
+ return pvol.FreeSize > 0 && !summary.some(sub => !sub["/"] && sub[pvol.path]);
+ }
+
+ const pvs_as_spaces = pvs_to_spaces(client, client.vgroups_pvols[vgroup.path].filter(usable));
+ const available = pvs_as_spaces.reduce((sum, spc) => sum + spc.size, 0);
+
+ if (available < missing) {
+ dialog_open({
+ Title: cockpit.format(_("Unable to repair logical volume $0"), lvol.Name),
+ Body:
{cockpit.format(_("There is not enough space available that could be used for a repair. At least $0 are needed on physical volumes that are not already used for this logical volume."),
+ fmt_size(missing))}
+ );
+};
+
+export const FilesystemPage = ({
+ page, backing_block, content_block, fstab_config, mismount_warning
+}) => {
+ function rename_dialog() {
+ // assert(content_block)
+ const block_fsys = client.blocks_fsys[content_block.path];
+
+ dialog_open({
+ Title: _("Filesystem name"),
+ Fields: [
+ TextInput("name", _("Name"),
+ {
+ validate: name => validate_fsys_label(name, content_block.IdType),
+ value: content_block.IdLabel
+ })
+ ],
+ Action: {
+ Title: _("Save"),
+ action: function (vals) {
+ return block_fsys.SetLabel(vals.name, {});
+ }
+ }
+ });
+ }
+
+ return (
+
+
+ }>
+
+
+
+
+
+
+
+ {content_block?.IdLabel || "-"}
+
+
+ {_("edit")}
+
+
+
+
+
+
+
+
+
+ { mismount_warning &&
+
+
+
+ }
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/iscsi-session.jsx b/pkg/storaged/pages/iscsi-session.jsx
new file mode 100644
index 000000000000..8a62484d3f3c
--- /dev/null
+++ b/pkg/storaged/pages/iscsi-session.jsx
@@ -0,0 +1,85 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { PageChildrenCard, new_page, page_type, ActionButtons } from "../pages.jsx";
+
+import { make_drive_page } from "./drive.jsx";
+
+const _ = cockpit.gettext;
+
+async function disconnect(session, goto_page) {
+ const loc = cockpit.location;
+ await session.Logout({ 'node.startup': { t: 's', v: "manual" } });
+ loc.go(goto_page.location);
+}
+
+export function make_iscsi_session_page(parent, session) {
+ const p = new_page({
+ location: ["iscsi", session.data.target_name],
+ parent,
+ name: session.data.target_name,
+ columns: [
+ _("iSCSI portal"),
+ session.data.persistent_address + ":" + session.data.persistent_port,
+ null,
+ ],
+ component: ISCSISessionPage,
+ props: { session },
+ actions: [
+ {
+ title: _("Disconnect"),
+ action: () => disconnect(session, parent),
+ danger: true
+ },
+ ]
+ });
+
+ if (client.iscsi_sessions_drives[session.path])
+ client.iscsi_sessions_drives[session.path].forEach(d => make_drive_page(p, d));
+}
+
+const ISCSISessionPage = ({ page, session }) => {
+ return (
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/locked-encrypted-data.jsx b/pkg/storaged/pages/locked-encrypted-data.jsx
new file mode 100644
index 000000000000..517167d1142c
--- /dev/null
+++ b/pkg/storaged/pages/locked-encrypted-data.jsx
@@ -0,0 +1,73 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { ParentPageLink, PageContainerStackItems, new_page, block_location, ActionButtons } from "../pages.jsx";
+import { format_dialog } from "../format-dialog.jsx";
+import { block_name, fmt_size } from "../utils.js";
+import { unlock } from "../actions.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_locked_encrypted_data_page(parent, block, container) {
+ new_page({
+ location: [block_location(block)],
+ parent,
+ container,
+ name: block_name(block),
+ columns: [
+ _("Locked encrypted data"),
+ null,
+ fmt_size(block.Size)
+ ],
+ component: LockedEncryptedDataPage,
+ props: { block },
+ actions: [
+ { title: _("Unlock"), action: () => unlock(block) },
+ { title: _("Format"), action: () => format_dialog(client, block.path), danger: true },
+ ]
+ });
+}
+
+export const LockedEncryptedDataPage = ({ page, block }) => {
+ return (
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/lvm2-inactive-logical-volume.jsx b/pkg/storaged/pages/lvm2-inactive-logical-volume.jsx
new file mode 100644
index 000000000000..b5b0eb26cba5
--- /dev/null
+++ b/pkg/storaged/pages/lvm2-inactive-logical-volume.jsx
@@ -0,0 +1,72 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+
+import { Card, CardHeader, CardTitle, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import {
+ ParentPageLink,
+ new_page, ActionButtons, page_type,
+} from "../pages.jsx";
+import { fmt_size } from "../utils.js";
+import { lvm2_delete_logical_volume_dialog, lvm2_create_snapshot_action } from "./lvm2-volume-group.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_lvm2_inactive_logical_volume_page(parent, vgroup, lvol) {
+ const page = new_page({
+ location: ["vg", vgroup.Name, lvol.Name],
+ parent,
+ name: lvol.Name,
+ columns: [
+ _("Inactive logical volume"),
+ null,
+ fmt_size(lvol.Size)
+ ],
+ component: LVM2InactiveLogicalVolumePage,
+ props: { vgroup, lvol },
+ actions: [
+ { title: _("Activate"), action: () => lvol.Activate({}) },
+ lvm2_create_snapshot_action(lvol),
+ { title: _("Delete"), action: () => lvm2_delete_logical_volume_dialog(lvol, page), danger: true },
+ ]
+ });
+}
+
+export const LVM2InactiveLogicalVolumePage = ({ page, vgroup, lvol }) => {
+ return (
+
+ }}>
+ {page_type(page)}
+
+
+
+
+ {_("Stored on")}
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/lvm2-physical-volume.jsx b/pkg/storaged/pages/lvm2-physical-volume.jsx
new file mode 100644
index 000000000000..4a598e61f64d
--- /dev/null
+++ b/pkg/storaged/pages/lvm2-physical-volume.jsx
@@ -0,0 +1,155 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { Card, CardHeader, CardTitle, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import {
+ ParentPageLink, PageContainerStackItems,
+ new_page, block_location, ActionButtons, page_type,
+ register_crossref,
+} from "../pages.jsx";
+import { format_dialog } from "../format-dialog.jsx";
+import { block_name, fmt_size } from "../utils.js";
+import { std_lock_action } from "../actions.jsx";
+
+const _ = cockpit.gettext;
+
+/* XXX - Unlike for make_filesystem_page, "content_block" is never null.
+ */
+
+export function make_lvm2_physical_volume_page(parent, backing_block, content_block, container) {
+ const block_pvol = client.blocks_pvol[content_block.path];
+ const vgroup = block_pvol && client.vgroups[block_pvol.VolumeGroup];
+
+ const p = new_page({
+ location: [block_location(backing_block)],
+ parent,
+ container,
+ name: block_name(backing_block),
+ columns: [
+ _("LVM2 physical volume"),
+ vgroup ? vgroup.Name : null,
+ fmt_size(backing_block.Size)
+ ],
+ component: LVM2PhysicalVolumePage,
+ props: { backing_block, content_block },
+ actions: [
+ std_lock_action(backing_block, content_block),
+ { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true },
+ ]
+ });
+
+ function pvol_remove() {
+ return vgroup.RemoveDevice(block_pvol.path, true, {});
+ }
+
+ function pvol_empty_and_remove() {
+ return (vgroup.EmptyDevice(block_pvol.path, {})
+ .then(function() {
+ vgroup.RemoveDevice(block_pvol.path, true, {});
+ }));
+ }
+
+ if (vgroup) {
+ const pvols = client.vgroups_pvols[vgroup.path] || [];
+ let remove_action = null;
+ let remove_excuse = null;
+
+ if (vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0) {
+ remove_excuse = _("Physical volumes can not be removed while a volume group is missing physical volumes.");
+ } else if (pvols.length === 1) {
+ remove_excuse = _("The last physical volume of a volume group cannot be removed.");
+ } else if (block_pvol.FreeSize < block_pvol.Size) {
+ if (block_pvol.Size <= vgroup.FreeSize)
+ remove_action = pvol_empty_and_remove;
+ else
+ remove_excuse = cockpit.format(
+ _("There is not enough free space elsewhere to remove this physical volume. At least $0 more free space is needed."),
+ fmt_size(block_pvol.Size - vgroup.FreeSize)
+ );
+ } else {
+ remove_action = pvol_remove;
+ }
+
+ register_crossref({
+ key: vgroup,
+ page: p,
+ actions: [
+ {
+ title: _("Remove"),
+ action: remove_action,
+ excuse: remove_excuse,
+ },
+ ],
+ size: cockpit.format(_("$0, $1 free"), fmt_size(block_pvol.Size), fmt_size(block_pvol.FreeSize)),
+ });
+ }
+}
+
+export const LVM2PhysicalVolumePage = ({ page, backing_block, content_block }) => {
+ const block_pvol = client.blocks_pvol[content_block.path];
+ const vgroup = block_pvol && client.vgroups[block_pvol.VolumeGroup];
+
+ return (
+
+
+
+ }}>
+ {page_type(page)}
+
+
+
+
+ {_("Stored on")}
+
+
+
+
+
+ {_("Volume group")}
+
+ {vgroup
+ ?
+ : "-"
+ }
+
+
+
+ {_("Free")}
+
+ {block_pvol ? fmt_size(block_pvol.FreeSize) : "-"}
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/lvm2-thin-pool-logical-volume.jsx b/pkg/storaged/pages/lvm2-thin-pool-logical-volume.jsx
new file mode 100644
index 000000000000..3d6a0a4b8449
--- /dev/null
+++ b/pkg/storaged/pages/lvm2-thin-pool-logical-volume.jsx
@@ -0,0 +1,142 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+
+import {
+ ParentPageLink, PageChildrenCard,
+ new_page, ActionButtons, page_type,
+} from "../pages.jsx";
+import { fmt_size, validate_lvm2_name } from "../utils.js";
+import {
+ dialog_open, TextInput, SizeSlider,
+} from "../dialog.jsx";
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { StorageLink, StorageButton } from "../storage-controls.jsx";
+import { grow_dialog } from "../resize.jsx";
+import { next_default_logical_volume_name } from "../content-views.jsx"; // XXX
+import { lvol_rename } from "../lvol-tabs.jsx"; // XXX
+import { make_lvm2_logical_volume_page, lvm2_delete_logical_volume_dialog } from "./lvm2-volume-group.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_lvm2_thin_pool_logical_volume_page(parent, vgroup, lvol) {
+ function create_thin() {
+ dialog_open({
+ Title: _("Create thin volume"),
+ Fields: [
+ TextInput("name", _("Name"),
+ {
+ value: next_default_logical_volume_name(client, vgroup, "lvol"),
+ validate: validate_lvm2_name
+ }),
+ SizeSlider("size", _("Size"),
+ {
+ value: lvol.Size,
+ max: lvol.Size * 3,
+ allow_infinite: true,
+ round: vgroup.ExtentSize
+ })
+ ],
+ Action: {
+ Title: _("Create"),
+ action: function (vals) {
+ return vgroup.CreateThinVolume(vals.name, vals.size, lvol.path, { });
+ }
+ }
+ });
+ }
+
+ const p = new_page({
+ location: ["vg", vgroup.Name, lvol.Name],
+ parent,
+ name: lvol.Name,
+ columns: [
+ _("Pool for thinly provisioned logical volumes"),
+ null,
+ fmt_size(lvol.Size)
+ ],
+ component: LVM2ThinPoolLogicalVolumePage,
+ props: { vgroup, lvol },
+ actions: [
+ { title: _("Create thinly provisioned logical volume"), action: create_thin },
+ { title: _("Delete"), action: () => lvm2_delete_logical_volume_dialog(lvol, p), danger: true },
+ ]
+ });
+
+ client.lvols_pool_members[lvol.path].forEach(member_lvol => {
+ make_lvm2_logical_volume_page(p, vgroup, member_lvol);
+ });
+}
+
+function perc(ratio) {
+ return (ratio * 100).toFixed(0) + "%";
+}
+
+export const LVM2ThinPoolLogicalVolumePage = ({ page, vgroup, lvol }) => {
+ function grow() {
+ grow_dialog(client, lvol, { });
+ }
+
+ return (
+
+
+ }>
+
+
+
+
+
+
+
+ {lvol.Name}
+
+ lvol_rename(lvol)}>
+ {_("edit")}
+
+
+
+
+
+ {fmt_size(lvol.Size)}
+
+ {_("Grow")}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/lvm2-unsupported-logical-volume.jsx b/pkg/storaged/pages/lvm2-unsupported-logical-volume.jsx
new file mode 100644
index 000000000000..99c59e128230
--- /dev/null
+++ b/pkg/storaged/pages/lvm2-unsupported-logical-volume.jsx
@@ -0,0 +1,74 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+
+import { Card, CardHeader, CardTitle, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import {
+ ParentPageLink,
+ new_page, ActionButtons, page_type,
+} from "../pages.jsx";
+import { fmt_size } from "../utils.js";
+import { lvm2_delete_logical_volume_dialog } from "./lvm2-volume-group.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_lvm2_unsupported_logical_volume_page(parent, vgroup, lvol) {
+ const page = new_page({
+ location: ["vg", vgroup.Name, lvol.Name],
+ parent,
+ name: lvol.Name,
+ columns: [
+ _("Unsupported logical volume"),
+ null,
+ fmt_size(lvol.Size)
+ ],
+ component: LVM2UnsupportedLogicalVolumePage,
+ props: { vgroup, lvol },
+ actions: [
+ { title: _("Deactivate"), action: () => lvol.Deactivate({}) },
+ { title: _("Delete"), action: () => lvm2_delete_logical_volume_dialog(lvol, page), danger: true },
+ ]
+ });
+}
+
+const LVM2UnsupportedLogicalVolumePage = ({ page, vgroup, lvol }) => {
+ return (
+
+ }}>
+ {page_type(page)}
+
+
+
+
+ {_("Stored on")}
+
+
+
+
+
+
+
+
{_("INTERNAL ERROR - This logical volume is marked as active and should have an associated block device. However, no such block device could be found.")}
+
+ );
+};
diff --git a/pkg/storaged/pages/lvm2-volume-group.jsx b/pkg/storaged/pages/lvm2-volume-group.jsx
new file mode 100644
index 000000000000..95f2ef9056db
--- /dev/null
+++ b/pkg/storaged/pages/lvm2-volume-group.jsx
@@ -0,0 +1,338 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+import { useObject } from "hooks";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { StorageButton } from "../storage-controls.jsx";
+import {
+ PageChildrenCard, PageCrossrefCard, ActionButtons, new_page, page_type, get_crossrefs, navigate_away_from_page
+} from "../pages.jsx";
+import {
+ fmt_size, fmt_size_long, get_active_usage, teardown_active_usage, for_each_async,
+ validate_lvm2_name,
+ get_available_spaces, prepare_available_spaces,
+ reload_systemd,
+} from "../utils.js";
+
+import {
+ dialog_open, SelectSpaces, TextInput,
+ BlockingMessage, TeardownMessage,
+ init_active_usage_processes
+} from "../dialog.jsx";
+
+import { vgroup_rename, vgroup_delete } from "../vgroup-details.jsx"; // XXX
+import { create_logical_volume } from "../content-views.jsx"; // XXX
+
+import { make_lvm2_logical_volume_container } from "../containers/lvm2-logical-volume.jsx";
+import { make_lvm2_thin_pool_logical_volume_page } from "./lvm2-thin-pool-logical-volume.jsx";
+import { make_lvm2_inactive_logical_volume_page } from "./lvm2-inactive-logical-volume.jsx";
+import { make_lvm2_unsupported_logical_volume_page } from "./lvm2-unsupported-logical-volume.jsx";
+import { make_block_page } from "../create-pages.jsx";
+
+const _ = cockpit.gettext;
+
+export function lvm2_delete_logical_volume_dialog(lvol, page) {
+ const vgroup = client.vgroups[lvol.VolumeGroup];
+ const usage = get_active_usage(client, lvol.path, _("delete"));
+
+ if (usage.Blocking) {
+ dialog_open({
+ Title: cockpit.format(_("$0 is in use"), lvol.Name),
+ Body: BlockingMessage(usage)
+ });
+ return;
+ }
+
+ dialog_open({
+ Title: cockpit.format(_("Permanently delete logical volume $0/$1?"), vgroup.Name, lvol.Name),
+ Teardown: TeardownMessage(usage),
+ Action: {
+ Danger: _("Deleting a logical volume will delete all data in it."),
+ Title: _("Delete"),
+ action: async function () {
+ await teardown_active_usage(client, usage);
+ await lvol.Delete({ 'tear-down': { t: 'b', v: true } });
+ await reload_systemd();
+ navigate_away_from_page(page);
+ }
+ },
+ Inits: [
+ init_active_usage_processes(client, usage)
+ ]
+ });
+}
+
+function create_snapshot(lvol) {
+ dialog_open({
+ Title: _("Create snapshot"),
+ Fields: [
+ TextInput("name", _("Name"),
+ { validate: validate_lvm2_name }),
+ ],
+ Action: {
+ Title: _("Create"),
+ action: function (vals) {
+ return lvol.CreateSnapshot(vals.name, vals.size || 0, { });
+ }
+ }
+ });
+}
+
+export function lvm2_create_snapshot_action(lvol) {
+ if (!client.lvols[lvol.ThinPool])
+ return null;
+
+ return { title: _("Create snapshot"), action: () => create_snapshot(lvol) };
+}
+
+export function make_lvm2_logical_volume_page(parent, vgroup, lvol) {
+ if (lvol.Type == "pool") {
+ make_lvm2_thin_pool_logical_volume_page(parent, vgroup, lvol);
+ } else {
+ const block = client.lvols_block[lvol.path];
+ if (block) {
+ const container = make_lvm2_logical_volume_container(null, vgroup, lvol, block);
+ make_block_page(parent, block, container);
+ } else {
+ // If we can't find the block for a active
+ // volume, Storaged or something below is
+ // probably misbehaving, and we show it as
+ // "unsupported".
+ if (lvol.Active) {
+ make_lvm2_unsupported_logical_volume_page(parent, vgroup, lvol);
+ } else {
+ make_lvm2_inactive_logical_volume_page(parent, vgroup, lvol);
+ }
+ }
+ }
+}
+
+function make_logical_volume_pages(parent, vgroup) {
+ const isVDOPool = lvol => Object.keys(client.vdo_vols).some(v => client.vdo_vols[v].VDOPool == lvol.path);
+
+ (client.vgroups_lvols[vgroup.path] || []).forEach(lvol => {
+ // Don't display VDO pool volumes as separate entities; they
+ // are an internal implementation detail and have no actions.
+ if (lvol.ThinPool == "/" && lvol.Origin == "/" && !isVDOPool(lvol))
+ make_lvm2_logical_volume_page(parent, vgroup, lvol);
+ });
+}
+
+export function make_lvm2_volume_group_page(parent, vgroup) {
+ const has_missing_pvs = vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0;
+ const vgroup_page = new_page({
+ location: ["vg", vgroup.Name],
+ parent,
+ name: vgroup.Name,
+ columns: [
+ _("LVM2 volume group"),
+ "/dev/" + vgroup.Name + "/",
+ fmt_size(vgroup.Size),
+ ],
+ has_warning: has_missing_pvs,
+ component: LVM2VolumeGroupPage,
+ props: { vgroup },
+ actions: [
+ {
+ title: _("Rename"),
+ action: () => vgroup_rename(client, vgroup),
+ excuse: has_missing_pvs && _("A volume group with missing physical volumes can not be renamed.")
+ },
+ { title: _("Delete"), action: () => vgroup_delete(client, vgroup, parent), danger: true },
+ ],
+ });
+
+ make_logical_volume_pages(vgroup_page, vgroup);
+}
+
+function vgroup_poller(vgroup) {
+ let timer = null;
+
+ if (vgroup.NeedsPolling) {
+ timer = window.setInterval(() => { vgroup.Poll() }, 2000);
+ }
+
+ function stop() {
+ if (timer)
+ window.clearInterval(timer);
+ }
+
+ return {
+ stop
+ };
+}
+
+const LVM2VolumeGroupPage = ({ page, vgroup }) => {
+ useObject(() => vgroup_poller(vgroup),
+ poller => poller.stop(),
+ [vgroup]);
+
+ function is_partial_linear_lvol(block) {
+ const lvm2 = client.blocks_lvm2[block.path];
+ const lvol = lvm2 && client.lvols[lvm2.LogicalVolume];
+ return lvol && lvol.Layout == "linear" && client.lvols_status[lvol.path] == "partial";
+ }
+
+ function remove_missing() {
+ /* Calling vgroup.RemoveMissingPhysicalVolumes will
+ implicitly delete all partial, linear logical volumes.
+ Instead of allowing this, we explicitly delete these
+ volumes before calling RemoveMissingPhysicalVolumes.
+ This allows us to kill processes that keep them busy
+ and remove their fstab entries.
+
+ RemoveMissingPhysicalVolumes leaves non-linear volumes
+ alone, even if they can't be repaired anymore. This is
+ a bit inconsistent, but *shrug*.
+ */
+
+ let usage = get_active_usage(client, vgroup.path, _("delete"));
+ usage = usage.filter(u => u.block && is_partial_linear_lvol(u.block));
+
+ if (usage.Blocking) {
+ dialog_open({
+ Title: cockpit.format(_("$0 is in use"),
+ vgroup.Name),
+ Body: BlockingMessage(usage)
+ });
+ return;
+ }
+
+ dialog_open({
+ Title: _("Remove missing physical volumes?"),
+ Teardown: TeardownMessage(usage),
+ Action: {
+ Title: _("Remove"),
+ action: function () {
+ return teardown_active_usage(client, usage)
+ .then(function () {
+ return for_each_async(usage,
+ u => {
+ const lvm2 = client.blocks_lvm2[u.block.path];
+ const lvol = lvm2 && client.lvols[lvm2.LogicalVolume];
+ return lvol.Delete({ 'tear-down': { t: 'b', v: true } });
+ })
+ .then(() => vgroup.RemoveMissingPhysicalVolumes({}));
+ });
+ }
+ },
+ Inits: [
+ init_active_usage_processes(client, usage)
+ ]
+ });
+ }
+
+ let alert = null;
+ if (vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0)
+ alert = (
+
+ {_("Dismiss")}}
+ title={_("This volume group is missing some physical volumes.")}>
+ {vgroup.MissingPhysicalVolumes.map(uuid =>
{uuid}
)}
+
+ );
+
+ function filter_inside_vgroup(spc) {
+ let block = spc.block;
+ if (client.blocks_part[block.path])
+ block = client.blocks[client.blocks_part[block.path].Table];
+ const lvol = (block &&
+ client.blocks_lvm2[block.path] &&
+ client.lvols[client.blocks_lvm2[block.path].LogicalVolume]);
+ return !lvol || lvol.VolumeGroup != vgroup.path;
+ }
+
+ function add_disk() {
+ dialog_open({
+ Title: _("Add disks"),
+ Fields: [
+ SelectSpaces("disks", _("Disks"),
+ {
+ empty_warning: _("No disks are available."),
+ validate: function(disks) {
+ if (disks.length === 0)
+ return _("At least one disk is needed.");
+ },
+ spaces: get_available_spaces(client).filter(filter_inside_vgroup)
+ })
+ ],
+ Action: {
+ Title: _("Add"),
+ action: function(vals) {
+ return prepare_available_spaces(client, vals.disks).then(paths =>
+ Promise.all(paths.map(p => vgroup.AddDevice(p, {}))));
+ }
+ }
+ });
+ }
+
+ const pvol_actions = (
+
+ {_("Add physical volume")}
+ );
+
+ let lvol_excuse = null;
+ if (vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0)
+ lvol_excuse = _("New logical volumes can not be created while a volume group is missing physical volumes.");
+ else if (vgroup.FreeSize == 0)
+ lvol_excuse = _("No free space");
+
+ const lvol_actions = (
+ create_logical_volume(client, vgroup)}
+ excuse={lvol_excuse}>
+ {_("Create new logical volume")}
+ );
+
+ return (
+
+ {alert}
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/mdraid-disk.jsx b/pkg/storaged/pages/mdraid-disk.jsx
new file mode 100644
index 000000000000..0702b9df8193
--- /dev/null
+++ b/pkg/storaged/pages/mdraid-disk.jsx
@@ -0,0 +1,156 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import {
+ ParentPageLink, PageContainerStackItems,
+ new_page, block_location, ActionButtons, page_type,
+ register_crossref,
+} from "../pages.jsx";
+import { format_dialog } from "../format-dialog.jsx";
+import { block_name, mdraid_name, fmt_size } from "../utils.js";
+import { std_lock_action } from "../actions.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_mdraid_disk_page(parent, backing_block, content_block, container) {
+ const mdraid = client.mdraids[content_block.MDRaidMember];
+
+ const p = new_page({
+ location: [block_location(backing_block)],
+ parent,
+ container,
+ name: block_name(backing_block),
+ columns: [
+ _("RAID disk"),
+ mdraid ? mdraid_name(mdraid) : null,
+ fmt_size(backing_block.Size)
+ ],
+ component: MDRaidDiskPage,
+ props: { backing_block, content_block, mdraid },
+ actions: [
+ std_lock_action(backing_block, content_block),
+ { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true },
+ ]
+ });
+
+ if (mdraid) {
+ const members = client.mdraids_members[mdraid.path] || [];
+ let n_spares = 0;
+ let n_recovering = 0;
+ mdraid.ActiveDevices.forEach(function(as) {
+ if (as[2].indexOf("spare") >= 0) {
+ if (as[1] < 0)
+ n_spares += 1;
+ else
+ n_recovering += 1;
+ }
+ });
+
+ /* Older versions of Udisks/storaged don't have a Running property */
+ let running = mdraid.Running;
+ if (running === undefined)
+ running = mdraid.ActiveDevices && mdraid.ActiveDevices.length > 0;
+
+ const active_state = mdraid.ActiveDevices.find(as => as[0] == content_block.path);
+
+ const state_text = (state) => {
+ return {
+ faulty: _("Failed"),
+ in_sync: _("In sync"),
+ spare: active_state[1] < 0 ? _("Spare") : _("Recovering"),
+ write_mostly: _("Write-mostly"),
+ blocked: _("Blocked")
+ }[state] || cockpit.format(_("Unknown ($0)"), state);
+ };
+
+ const slot = active_state && active_state[1] >= 0 && active_state[1].toString();
+ let states = active_state && active_state[2].map(state_text).join(", ");
+
+ if (slot)
+ states = cockpit.format(_("Slot $0"), slot) + ", " + states;
+
+ const is_in_sync = (active_state && active_state[2].indexOf("in_sync") >= 0);
+ const is_recovering = (active_state && active_state[2].indexOf("spare") >= 0 && active_state[1] >= 0);
+
+ let remove_excuse = false;
+ if (!running)
+ remove_excuse = _("The RAID device must be running in order to remove disks.");
+ else if ((is_in_sync && n_recovering > 0) || is_recovering)
+ remove_excuse = _("This disk cannot be removed while the device is recovering.");
+ else if (is_in_sync && n_spares < 1)
+ remove_excuse = _("A spare disk needs to be added first before this disk can be removed.");
+ else if (members.length <= 1)
+ remove_excuse = _("The last disk of a RAID device cannot be removed.");
+
+ let remove_action = null;
+ if (mdraid.Level != "raid0")
+ remove_action = {
+ title: _("Remove"),
+ action: () => mdraid.RemoveDevice(content_block.path, { wipe: { t: 'b', v: true } }),
+ excuse: remove_excuse
+ };
+
+ register_crossref({
+ key: mdraid,
+ page: p,
+ actions: [
+ remove_action
+ ],
+ size: states,
+ });
+ }
+}
+
+export const MDRaidDiskPage = ({ page, backing_block, content_block, mdraid }) => {
+ return (
+
+
+ }>
+
+
+
+
+
+
+ {mdraid
+ ?
+ : "-"
+ }
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/mdraid.jsx b/pkg/storaged/pages/mdraid.jsx
new file mode 100644
index 000000000000..4af1c3bcf07b
--- /dev/null
+++ b/pkg/storaged/pages/mdraid.jsx
@@ -0,0 +1,323 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { StorageButton } from "../storage-controls.jsx";
+import { PageChildrenCard, PageCrossrefCard, ActionButtons, new_page, get_crossrefs, page_type } from "../pages.jsx";
+import {
+ block_name, mdraid_name, encode_filename, decode_filename,
+ fmt_size, fmt_size_long, get_active_usage, teardown_active_usage,
+ get_available_spaces, prepare_available_spaces,
+ reload_systemd,
+} from "../utils.js";
+
+import {
+ dialog_open, SelectSpaces,
+ BlockingMessage, TeardownMessage,
+ init_active_usage_processes
+} from "../dialog.jsx";
+
+import { make_block_pages } from "../create-pages.jsx";
+
+import { format_disk } from "../content-views.jsx"; // XXX
+
+const _ = cockpit.gettext;
+
+function mdraid_start(mdraid) {
+ return mdraid.Start({ "start-degraded": { t: 'b', v: true } });
+}
+
+function mdraid_stop(mdraid) {
+ const block = client.mdraids_block[mdraid.path];
+ const usage = get_active_usage(client, block ? block.path : "", _("stop"));
+
+ if (usage.Blocking) {
+ dialog_open({
+ Title: cockpit.format(_("$0 is in use"), mdraid_name(mdraid)),
+ Body: BlockingMessage(usage),
+ });
+ return;
+ }
+
+ if (usage.Teardown) {
+ dialog_open({
+ Title: cockpit.format(_("Confirm stopping of $0"),
+ mdraid_name(mdraid)),
+ Teardown: TeardownMessage(usage),
+ Action: {
+ Title: _("Stop device"),
+ action: function () {
+ return teardown_active_usage(client, usage)
+ .then(function () {
+ return mdraid.Stop({});
+ });
+ }
+ },
+ Inits: [
+ init_active_usage_processes(client, usage)
+ ]
+ });
+ return;
+ }
+
+ return mdraid.Stop({});
+}
+
+function mdraid_delete(mdraid, block) {
+ const location = cockpit.location;
+
+ function delete_() {
+ if (mdraid.Delete)
+ return mdraid.Delete({ 'tear-down': { t: 'b', v: true } }).then(reload_systemd);
+
+ // If we don't have a Delete method, we simulate
+ // it by stopping the array and wiping all
+ // members.
+
+ function wipe_members() {
+ return Promise.all(client.mdraids_members[mdraid.path].map(member => member.Format('empty', { })));
+ }
+
+ if (mdraid.ActiveDevices && mdraid.ActiveDevices.length > 0)
+ return mdraid.Stop({}).then(wipe_members);
+ else
+ return wipe_members();
+ }
+
+ const usage = get_active_usage(client, block ? block.path : "", _("delete"));
+
+ if (usage.Blocking) {
+ dialog_open({
+ Title: cockpit.format(_("$0 is in use"), mdraid_name(mdraid)),
+ Body: BlockingMessage(usage)
+ });
+ return;
+ }
+
+ dialog_open({
+ Title: cockpit.format(_("Permanently delete $0?"), mdraid_name(mdraid)),
+ Teardown: TeardownMessage(usage),
+ Action: {
+ Title: _("Delete"),
+ Danger: _("Deleting erases all data on a RAID device."),
+ action: function () {
+ return teardown_active_usage(client, usage)
+ .then(delete_)
+ .then(function () {
+ location.go('/');
+ });
+ }
+ },
+ Inits: [
+ init_active_usage_processes(client, usage)
+ ]
+ });
+}
+
+function start_stop_action(mdraid) {
+ let running = mdraid.Running;
+ if (running === undefined)
+ running = mdraid.ActiveDevices && mdraid.ActiveDevices.length > 0;
+
+ if (running)
+ return { title: _("Stop"), action: () => mdraid_stop(mdraid) };
+ else
+ return { title: _("Start"), action: () => mdraid_start(mdraid) };
+}
+
+export function make_mdraid_page(parent, mdraid) {
+ const block = client.mdraids_block[mdraid.path];
+
+ const p = new_page({
+ location: ["mdraid", mdraid.UUID],
+ parent,
+ name: mdraid_name(mdraid),
+ columns: [
+ _("RAID device"),
+ block ? block_name(block) : null,
+ fmt_size(mdraid.Size),
+ ],
+ component: MDRaidPage,
+ props: { mdraid, block },
+ actions: [
+ start_stop_action(mdraid),
+ { title: _("Delete"), action: () => mdraid_delete(mdraid, block), danger: true },
+ ],
+ });
+
+ if (block)
+ make_block_pages(p, block);
+}
+
+const MDRaidPage = ({ page, mdraid, block }) => {
+ function format_level(str) {
+ return {
+ raid0: _("RAID 0"),
+ raid1: _("RAID 1"),
+ raid4: _("RAID 4"),
+ raid5: _("RAID 5"),
+ raid6: _("RAID 6"),
+ raid10: _("RAID 10")
+ }[str] || cockpit.format(_("RAID ($0)"), str);
+ }
+
+ let level = format_level(mdraid.Level);
+ if (mdraid.NumDevices > 0)
+ level += ", " + cockpit.format(_("$0 disks"), mdraid.NumDevices);
+ if (mdraid.ChunkSize > 0)
+ level += ", " + cockpit.format(_("$0 chunk size"), fmt_size(mdraid.ChunkSize));
+
+ let degraded_message = null;
+ if (mdraid.Degraded > 0) {
+ const text = cockpit.format(
+ cockpit.ngettext("$0 disk is missing", "$0 disks are missing", mdraid.Degraded),
+ mdraid.Degraded
+ );
+ degraded_message = (
+
+
+ {text}
+
+
+ );
+ }
+
+ function fix_bitmap() {
+ return mdraid.SetBitmapLocation(encode_filename("internal"), { });
+ }
+
+ let bitmap_message = null;
+ if (mdraid.Level != "raid0" &&
+ client.mdraids_members[mdraid.path].some(m => m.Size > 100 * 1024 * 1024 * 1024) &&
+ mdraid.BitmapLocation && decode_filename(mdraid.BitmapLocation) == "none") {
+ bitmap_message = (
+
+
+
+ {_("Add a bitmap")}
+
+
+
+ );
+ }
+
+ /* Older versions of Udisks/storaged don't have a Running property */
+ let running = mdraid.Running;
+ if (running === undefined)
+ running = mdraid.ActiveDevices && mdraid.ActiveDevices.length > 0;
+
+ let content = null;
+ if (block) {
+ const is_partitioned = !!client.blocks_ptable[block.path];
+ const actions = (
+ format_disk(client, block)}
+ excuse={block.ReadOnly ? _("Device is read-only") : null}>
+ {_("Create partition table")}
+ );
+
+ content = (
+
+
+ );
+ }
+
+ function filter_inside_mdraid(spc) {
+ let block = spc.block;
+ if (client.blocks_part[block.path])
+ block = client.blocks[client.blocks_part[block.path].Table];
+ return block && block.MDRaid != mdraid.path;
+ }
+
+ function rescan(path) {
+ // mdraid often forgets to trigger udev, let's do it explicitly
+ return client.wait_for(() => client.blocks[path]).then(block => block.Rescan({ }));
+ }
+
+ function add_disk() {
+ dialog_open({
+ Title: _("Add disks"),
+ Fields: [
+ SelectSpaces("disks", _("Disks"),
+ {
+ empty_warning: _("No disks are available."),
+ validate: function (disks) {
+ if (disks.length === 0)
+ return _("At least one disk is needed.");
+ },
+ spaces: get_available_spaces(client).filter(filter_inside_mdraid)
+ })
+ ],
+ Action: {
+ Title: _("Add"),
+ action: function(vals) {
+ return prepare_available_spaces(client, vals.disks).then(paths =>
+ Promise.all(paths.map(p => mdraid.AddDevice(p, {}).then(() => rescan(p)))));
+ }
+ }
+ });
+ }
+
+ let add_excuse = false;
+ if (!running)
+ add_excuse = _("The RAID device must be running in order to add spare disks.");
+
+ let add_action = null;
+ if (mdraid.Level != "raid0")
+ add_action = (
+
+ {_("Add disk")}
+ );
+
+ return (
+
+ {bitmap_message}
+ {degraded_message}
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { content }
+
+ );
+};
diff --git a/pkg/storaged/pages/nfs.jsx b/pkg/storaged/pages/nfs.jsx
new file mode 100644
index 000000000000..7ef1c89eeb58
--- /dev/null
+++ b/pkg/storaged/pages/nfs.jsx
@@ -0,0 +1,339 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import {
+ dialog_open, TextInput, ComboBox, CheckBoxes,
+ StopProcessesMessage, stop_processes_danger_message
+} from "../dialog.jsx";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { StorageUsageBar } from "../storage-controls.jsx";
+import { new_page, page_type, ActionButtons } from "../pages.jsx";
+import { parse_options, unparse_options, extract_option } from "../utils.js";
+
+const _ = cockpit.gettext;
+
+function nfs_busy_dialog(client, dialog_title, entry, error, action_title, action) {
+ function show(users) {
+ if (users.length === 0) {
+ dialog_open({
+ Title: dialog_title,
+ Body: error.toString()
+ });
+ } else {
+ dialog_open({
+ Title: dialog_title,
+ Teardown: ,
+ Action: {
+ DangerButton: true,
+ Danger: stop_processes_danger_message(users),
+ Title: action_title,
+ action: function () {
+ return action(users);
+ }
+ }
+ });
+ }
+ }
+
+ client.nfs.entry_users(entry)
+ .then(function (users) {
+ show(users);
+ })
+ .catch(function () {
+ show([]);
+ });
+}
+
+function get_exported_directories(server) {
+ return cockpit.spawn(["showmount", "-e", "--no-headers", server], { err: "message" })
+ .then(function (output) {
+ const dirs = [];
+ output.split("\n").forEach(function (line) {
+ const d = line.split(" ")[0];
+ if (d)
+ dirs.push(d);
+ });
+ return dirs;
+ });
+}
+
+export function nfs_fstab_dialog(client, entry) {
+ const mount_options = entry ? entry.fields[3] : "defaults";
+ const split_options = parse_options(mount_options == "defaults" ? "" : mount_options);
+ const opt_auto = !extract_option(split_options, "noauto");
+ const opt_ro = extract_option(split_options, "ro");
+ const extra_options = unparse_options(split_options);
+
+ function mounting_options(vals) {
+ let opts = [];
+ if (!vals.mount_options.auto)
+ opts.push("noauto");
+ if (vals.mount_options.ro)
+ opts.push("ro");
+ if (vals.mount_options.extra !== false)
+ opts = opts.concat(parse_options(vals.mount_options.extra));
+ return unparse_options(opts);
+ }
+
+ function show(busy) {
+ let alert = null;
+ if (busy)
+ alert = <>
+
+
+ >;
+
+ let server_to_check = null;
+ let server_check_timeout = null;
+
+ function check_server(dlg, server, delay) {
+ if (server_check_timeout)
+ window.clearTimeout(server_check_timeout);
+ server_to_check = server;
+ server_check_timeout = window.setTimeout(() => {
+ server_check_timeout = null;
+ dlg.set_options("remote", { choices: [] });
+ get_exported_directories(server).then(choices => {
+ if (server == server_to_check)
+ dlg.set_options("remote", { choices });
+ });
+ }, delay);
+ }
+
+ const dlg = dialog_open({
+ Title: entry ? _("NFS mount") : _("New NFS mount"),
+ Body: alert,
+ Fields: [
+ TextInput("server", _("Server address"),
+ {
+ value: entry ? entry.fields[0].split(":")[0] : "",
+ validate: function (val) {
+ if (val === "")
+ return _("Server cannot be empty.");
+ },
+ disabled: busy
+ }),
+ ComboBox("remote", _("Path on server"),
+ {
+ value: entry ? entry.fields[0].split(":")[1] : "",
+ validate: function (val) {
+ if (val === "")
+ return _("Path on server cannot be empty.");
+ if (val[0] !== "/")
+ return _("Path on server must start with \"/\".");
+ },
+ disabled: busy,
+ choices: [],
+ }),
+ TextInput("dir", _("Local mount point"),
+ {
+ value: entry ? entry.fields[1] : "",
+ validate: function (val) {
+ if (val === "")
+ return _("Mount point cannot be empty.");
+ if (val[0] !== "/")
+ return _("Mount point must start with \"/\".");
+ },
+ disabled: busy
+ }),
+ CheckBoxes("mount_options", _("Mount options"),
+ {
+ fields: [
+ { title: _("Mount at boot"), tag: "auto" },
+ { title: _("Mount read only"), tag: "ro" },
+ { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" },
+ ],
+ value: {
+ auto: opt_auto,
+ ro: opt_ro,
+ extra: extra_options === "" ? false : extra_options
+ }
+ },
+ ),
+ ],
+ update: (dlg, vals, trigger) => {
+ if (trigger === "server")
+ check_server(dlg, vals.server, 500);
+ },
+ Action: {
+ Title: entry ? _("Save") : _("Add"),
+ action: function (vals) {
+ const location = cockpit.location;
+ const fields = [vals.server + ":" + vals.remote,
+ vals.dir,
+ entry ? entry.fields[2] : "nfs",
+ mounting_options(vals) || "defaults"];
+ if (entry) {
+ return client.nfs.update_entry(entry, fields)
+ .then(function () {
+ if (entry.fields[0] != fields[0] ||
+ entry.fields[1] != fields[1])
+ location.go(["nfs", fields[0], fields[1]]);
+ });
+ } else
+ return client.nfs.add_entry(fields);
+ }
+ }
+ });
+
+ if (entry && !busy)
+ check_server(dlg, entry.fields[0].split(":")[0], 0);
+ }
+
+ if (entry) {
+ client.nfs.entry_users(entry)
+ .then(function (users) {
+ show(users.length > 0);
+ })
+ .catch(function () {
+ show(false);
+ });
+ } else
+ show(false);
+}
+
+function checked(error_title, promise) {
+ promise.catch(error => {
+ dialog_open({
+ Title: error_title,
+ Body: error.toString()
+ });
+ });
+}
+
+function mount(client, entry) {
+ checked("Could not mount the filesystem",
+ client.nfs.mount_entry(entry));
+}
+
+function unmount(client, entry) {
+ const location = cockpit.location;
+ client.nfs.unmount_entry(entry)
+ .then(function () {
+ if (!entry.fstab)
+ location.go("/");
+ })
+ .catch(function (error) {
+ nfs_busy_dialog(client,
+ _("Unable to unmount filesystem"),
+ entry, error,
+ _("Stop and unmount"),
+ function (users) {
+ return client.nfs.stop_and_unmount_entry(users, entry)
+ .then(function () {
+ if (!entry.fstab)
+ location.go("/");
+ });
+ });
+ });
+}
+
+function edit(client, entry) {
+ nfs_fstab_dialog(client, entry);
+}
+
+function remove(client, entry) {
+ const location = cockpit.location;
+ client.nfs.remove_entry(entry)
+ .then(function () {
+ location.go("/");
+ })
+ .catch(function (error) {
+ nfs_busy_dialog(client,
+ _("Unable to remove mount"),
+ entry, error,
+ _("Stop and remove"),
+ function (users) {
+ return client.nfs.stop_and_remove_entry(users, entry)
+ .then(function () {
+ location.go("/");
+ });
+ });
+ });
+}
+
+const NfsEntryUsageBar = ({ entry, not_mounted_text }) => {
+ if (entry.mounted)
+ return ;
+ else
+ return not_mounted_text;
+};
+
+export function make_nfs_page(parent, entry) {
+ const remote = entry.fields[0];
+ const local = entry.fields[1];
+ let mount_point = local;
+ if (!entry.mounted)
+ mount_point += " " + _("(not mounted)");
+
+ new_page({
+ location: ["nfs", remote, local],
+ parent,
+ name: remote,
+ columns: [
+ _("NFS mount"),
+ mount_point,
+ ,
+ ],
+ component: NfsPage,
+ props: { entry },
+ actions: [
+ (entry.mounted
+ ? { title: _("Unmount"), action: () => unmount(client, entry) }
+ : { title: _("Mount"), action: () => mount(client, entry) }),
+ (entry.fstab
+ ? { title: _("Edit"), action: () => edit(client, entry) }
+ : null),
+ (entry.fstab
+ ? { title: _("Remove"), action: () => remove(client, entry), danger: true }
+ : null),
+ ]
+ });
+}
+
+const NfsPage = ({ page, entry }) => {
+ return (
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/other.jsx b/pkg/storaged/pages/other.jsx
new file mode 100644
index 000000000000..932ea23ce460
--- /dev/null
+++ b/pkg/storaged/pages/other.jsx
@@ -0,0 +1,84 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { StorageButton } from "../storage-controls.jsx";
+import { PageChildrenCard, new_page, page_type, block_location } from "../pages.jsx";
+import { block_name, fmt_size } from "../utils.js";
+import { format_disk } from "../content-views.jsx"; // XXX
+
+import { make_block_pages } from "../create-pages.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_other_page(parent, block) {
+ const p = new_page({
+ location: ["other", block_location(block)],
+ parent,
+ name: block_location(block),
+ columns: [
+ _("Block device"),
+ block_name(block),
+ fmt_size(block.Size)
+ ],
+ component: OtherPage,
+ props: { block }
+ });
+
+ make_block_pages(p, block, null);
+}
+
+const OtherPage = ({ page, block }) => {
+ const is_partitioned = !!client.blocks_ptable[block.path];
+
+ const actions =
+ format_disk(client, block)}
+ excuse={block.ReadOnly ? _("Device is read-only") : null}>
+ {_("Create partition table")}
+ ;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/overview.jsx b/pkg/storaged/pages/overview.jsx
new file mode 100644
index 000000000000..0ba6ab074eb0
--- /dev/null
+++ b/pkg/storaged/pages/overview.jsx
@@ -0,0 +1,165 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { install_dialog } from "cockpit-components-install-dialog.jsx";
+
+import { Card, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+
+import { StoragePlots } from "../plot.jsx";
+import { StorageMenuItem, StorageBarMenu } from "../storage-controls.jsx";
+import { dialog_open } from "../dialog.jsx";
+import { StorageLogsPanel } from "../logs-panel.jsx";
+
+import { create_mdraid } from "../mdraids-panel.jsx"; // XXX
+import { create_vgroup } from "../vgroups-panel.jsx"; // XXX
+import { create_stratis_pool } from "../stratis-panel.jsx"; // XXX
+import { iscsi_change_name, iscsi_discover } from "../iscsi-panel.jsx"; // XXX
+import { get_other_devices } from "../utils.js"; // XXX
+
+import { new_page, PageChildrenCard } from "../pages.jsx";
+import { make_drive_page } from "./drive.jsx";
+import { make_lvm2_volume_group_page } from "./lvm2-volume-group.jsx";
+import { make_mdraid_page } from "./mdraid.jsx";
+import { make_stratis_pool_page } from "./stratis-pool.jsx";
+import { make_stratis_stopped_pool_page } from "./stratis-stopped-pool.jsx";
+import { make_nfs_page, nfs_fstab_dialog } from "./nfs.jsx";
+import { make_iscsi_session_page } from "./iscsi-session.jsx";
+import { make_other_page } from "./other.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_overview_page() {
+ const overview_page = new_page({
+ location: [],
+ name: _("Storage"),
+ component: OverviewPage
+ });
+
+ Object.keys(client.iscsi_sessions).forEach(p => make_iscsi_session_page(overview_page, client.iscsi_sessions[p]));
+ Object.keys(client.drives).forEach(p => {
+ if (!client.drives_iscsi_session[p])
+ make_drive_page(overview_page, client.drives[p]);
+ });
+ Object.keys(client.vgroups).forEach(p => make_lvm2_volume_group_page(overview_page, client.vgroups[p]));
+ Object.keys(client.mdraids).forEach(p => make_mdraid_page(overview_page, client.mdraids[p]));
+ Object.keys(client.stratis_pools).map(p => make_stratis_pool_page(overview_page, client.stratis_pools[p]));
+ Object.keys(client.stratis_manager.StoppedPools).map(uuid => make_stratis_stopped_pool_page(overview_page, uuid));
+ client.nfs.entries.forEach(e => make_nfs_page(overview_page, e));
+ get_other_devices(client).map(p => make_other_page(overview_page, client.blocks[p]));
+}
+
+const OverviewPage = ({ page, plot_state }) => {
+ function menu_item(feature, title, action) {
+ const feature_enabled = !feature || feature.is_enabled();
+ const required_package = feature && feature.package;
+
+ if (!feature_enabled && !(required_package && client.features.packagekit))
+ return null;
+
+ function install_then_action() {
+ if (!feature_enabled) {
+ install_dialog(required_package, feature.dialog_options).then(
+ () => {
+ feature.enable()
+ .then(action)
+ .catch(error => {
+ dialog_open({
+ Title: _("Error"),
+ Body: error.toString()
+ });
+ });
+ },
+ () => null /* ignore cancel */);
+ } else {
+ action();
+ }
+ }
+
+ return {title};
+ }
+
+ const lvm2_feature = {
+ is_enabled: () => client.features.lvm2
+ };
+
+ const stratis_feature = {
+ is_enabled: () => client.features.stratis,
+ package: client.get_config("stratis_package", false),
+ enable: () => {
+ return cockpit.spawn(["systemctl", "start", "stratisd"], { superuser: true })
+ .then(() => client.stratis_start());
+ },
+
+ dialog_options: {
+ title: _("Install Stratis support"),
+ text: _("The $0 package must be installed to create Stratis pools.")
+ }
+ };
+
+ const nfs_feature = {
+ is_enabled: () => client.features.nfs,
+ package: client.get_config("nfs_client_package", false),
+ enable: () => {
+ client.features.nfs = true;
+ client.nfs.start();
+ return Promise.resolve();
+ },
+
+ dialog_options: {
+ title: _("Install NFS support")
+ }
+ };
+
+ const iscsi_feature = {
+ is_enabled: () => client.features.iscsi,
+ };
+
+ const menu_items = [
+ menu_item(null, _("Create RAID device"), () => create_mdraid(client)),
+ menu_item(lvm2_feature, _("Create LVM2 volume group"), () => create_vgroup(client)),
+ menu_item(stratis_feature, _("Create Stratis pool"), () => create_stratis_pool(client)),
+ menu_item(nfs_feature, _("New NFS mount"), () => nfs_fstab_dialog(client, null)),
+ menu_item(iscsi_feature, _("Change iSCSI initiater name"), () => iscsi_change_name(client)),
+ menu_item(iscsi_feature, _("Add iSCSI portal"), () => iscsi_discover(client)),
+ ].filter(item => item !== null);
+
+ const actions = ;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/stratis-blockdev.jsx b/pkg/storaged/pages/stratis-blockdev.jsx
new file mode 100644
index 000000000000..454236caff65
--- /dev/null
+++ b/pkg/storaged/pages/stratis-blockdev.jsx
@@ -0,0 +1,121 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { Card, CardHeader, CardTitle, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import {
+ ParentPageLink, PageContainerStackItems,
+ new_page, block_location, ActionButtons, page_type,
+ register_crossref,
+} from "../pages.jsx";
+import { format_dialog } from "../format-dialog.jsx";
+import { block_name, fmt_size } from "../utils.js";
+import { std_lock_action } from "../actions.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_stratis_blockdev_page(parent, backing_block, content_block, container) {
+ const blockdev = client.blocks_stratis_blockdev[content_block.path];
+ const pool = blockdev && client.stratis_pools[blockdev.Pool];
+ const stopped_pool = client.blocks_stratis_stopped_pool[content_block.path];
+
+ const p = new_page({
+ location: [block_location(backing_block)],
+ parent,
+ container,
+ name: block_name(backing_block),
+ columns: [
+ _("Stratis block device"),
+ pool ? pool.Name : stopped_pool,
+ fmt_size(backing_block.Size)
+ ],
+ component: StratisBlockdevPage,
+ props: { backing_block, content_block, pool, stopped_pool },
+ actions: [
+ std_lock_action(backing_block, content_block),
+ { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true },
+ ]
+ });
+
+ let desc;
+ if (blockdev && blockdev.Tier == 0)
+ desc = cockpit.format(_("$0 data"),
+ fmt_size(Number(blockdev.TotalPhysicalSize)));
+ else if (blockdev && blockdev.Tier == 1)
+ desc = cockpit.format(_("$0 cache"),
+ fmt_size(Number(blockdev.TotalPhysicalSize)));
+ else
+ desc = cockpit.format(_("$0 of unknown tier"),
+ fmt_size(backing_block.Size));
+
+ if (pool || stopped_pool) {
+ register_crossref({
+ key: pool || stopped_pool,
+ page: p,
+ size: desc,
+ actions: [],
+ });
+ }
+}
+
+export const StratisBlockdevPage = ({ page, backing_block, content_block, pool, stopped_pool }) => {
+ const pool_name = pool ? pool.Name : stopped_pool;
+ const pool_uuid = pool ? pool.Uuid : stopped_pool;
+
+ return (
+
+
+
+ }}>
+ {page_type(page)}
+
+
+
+
+ {_("Stored on")}
+
+
+
+
+
+ {_("Stratis pool")}
+
+ {(pool || stopped_pool)
+ ?
+ : "-"
+ }
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/stratis-filesystem.jsx b/pkg/storaged/pages/stratis-filesystem.jsx
new file mode 100644
index 000000000000..bea175e0169d
--- /dev/null
+++ b/pkg/storaged/pages/stratis-filesystem.jsx
@@ -0,0 +1,266 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import {
+ dialog_open, TextInput, CheckBoxes, SelectOne, BlockingMessage, TeardownMessage,
+ init_active_usage_processes,
+} from "../dialog.jsx";
+import { StorageUsageBar } from "../storage-controls.jsx";
+import {
+ ParentPageLink, PageContainerStackItems,
+ new_page, ActionButtons, page_type,
+} from "../pages.jsx";
+import { is_valid_mount_point, is_mounted, mounting_dialog, get_fstab_config } from "../fsys-tab.jsx"; // XXX
+import { fmt_size, get_active_usage, teardown_active_usage } from "../utils.js";
+import { std_reply } from "../stratis-utils.js";
+import { validate_fs_name, set_mount_options, destroy_filesystem } from "./stratis-pool.jsx"; // XXX
+import { mount_explanation } from "../format-dialog.jsx";
+import { MountPoint, MismountAlert, check_mismounted_fsys } from "./filesystem.jsx";
+
+const _ = cockpit.gettext;
+
+export function make_stratis_filesystem_page(parent, pool, fsys,
+ offset, forced_options, managed_fsys_sizes) {
+ const filesystems = client.stratis_pool_filesystems[pool.path];
+ const stats = client.stratis_pool_stats[pool.path];
+ const block = client.slashdevs_block[fsys.Devnode];
+
+ if (!block)
+ return;
+
+ const fstab_config = get_fstab_config(block);
+ const [, mount_point] = fstab_config;
+ const fs_is_mounted = is_mounted(client, block);
+
+ const mismount_warning = check_mismounted_fsys(block, block, fstab_config);
+
+ function mount() {
+ return mounting_dialog(client, block, "mount", forced_options);
+ }
+
+ function unmount() {
+ return mounting_dialog(client, block, "unmount", forced_options);
+ }
+
+ function rename_fsys() {
+ dialog_open({
+ Title: _("Rename filesystem"),
+ Fields: [
+ TextInput("name", _("Name"),
+ {
+ value: fsys.Name,
+ validate: name => validate_fs_name(fsys, name, filesystems)
+ })
+ ],
+ Action: {
+ Title: _("Rename"),
+ action: function (vals) {
+ return fsys.SetName(vals.name).then(std_reply);
+ }
+ }
+ });
+ }
+
+ function snapshot_fsys() {
+ if (managed_fsys_sizes && stats.pool_free < Number(fsys.Size)) {
+ dialog_open({
+ Title: _("Not enough space"),
+ Body: cockpit.format(_("There is not enough space in the pool to make a snapshot of this filesystem. At least $0 are required but only $1 are available."),
+ fmt_size(Number(fsys.Size)), fmt_size(stats.pool_free))
+ });
+ return;
+ }
+
+ dialog_open({
+ Title: cockpit.format(_("Create a snapshot of filesystem $0"), fsys.Name),
+ Fields: [
+ TextInput("name", _("Name"),
+ {
+ value: "",
+ validate: name => validate_fs_name(null, name, filesystems)
+ }),
+ TextInput("mount_point", _("Mount point"),
+ {
+ validate: (val, values, variant) => {
+ return is_valid_mount_point(client, null, val, variant == "nomount");
+ }
+ }),
+ CheckBoxes("mount_options", _("Mount options"),
+ {
+ value: {
+ ro: false,
+ extra: false
+ },
+ fields: [
+ { title: _("Mount read only"), tag: "ro" },
+ { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" },
+ ]
+ }),
+ SelectOne("at_boot", _("At boot"),
+ {
+ value: "nofail",
+ explanation: mount_explanation.nofail,
+ choices: [
+ {
+ value: "local",
+ title: _("Mount before services start"),
+ },
+ {
+ value: "nofail",
+ title: _("Mount without waiting, ignore failure"),
+ },
+ {
+ value: "netdev",
+ title: _("Mount after network becomes available, ignore failure"),
+ },
+ {
+ value: "never",
+ title: _("Do not mount"),
+ },
+ ]
+ }),
+ ],
+ update: function (dlg, vals, trigger) {
+ if (trigger == "at_boot")
+ dlg.set_options("at_boot", { explanation: mount_explanation[vals.at_boot] });
+ },
+ Action: {
+ Title: _("Create snapshot and mount"),
+ Variants: [{ tag: "nomount", Title: _("Create snapshot only") }],
+ action: function (vals) {
+ return pool.SnapshotFilesystem(fsys.path, vals.name)
+ .then(std_reply)
+ .then(result => {
+ if (result[0])
+ return set_mount_options(result[1], vals, forced_options);
+ else
+ return Promise.resolve();
+ });
+ }
+ }
+ });
+ }
+
+ function delete_fsys() {
+ const usage = get_active_usage(client, block.path, _("delete"));
+
+ if (usage.Blocking) {
+ dialog_open({
+ Title: cockpit.format(_("$0 is in use"),
+ fsys.Name),
+ Body: BlockingMessage(usage)
+ });
+ return;
+ }
+
+ dialog_open({
+ Title: cockpit.format(_("Confirm deletion of $0"), fsys.Name),
+ Teardown: TeardownMessage(usage),
+ Action: {
+ Danger: _("Deleting a filesystem will delete all data in it."),
+ Title: _("Delete"),
+ action: function () {
+ return teardown_active_usage(client, usage)
+ .then(() => destroy_filesystem(fsys));
+ }
+ },
+ Inits: [
+ init_active_usage_processes(client, usage)
+ ]
+ });
+ }
+
+ let mp_text;
+ if (mount_point && fs_is_mounted)
+ mp_text = mount_point;
+ else if (mount_point && !fs_is_mounted)
+ mp_text = mount_point + " " + _("(not mounted)");
+ else
+ mp_text = _("(not mounted)");
+
+ new_page({
+ location: ["pool", pool.Name, fsys.Name],
+ parent,
+ name: fsys.Name,
+ columns: [
+ _("Stratis filesystem"),
+ mp_text,
+ (!managed_fsys_sizes
+ ?
+ : )
+ ],
+ has_warning: !!mismount_warning,
+ component: StratisFilesystemPage,
+ props: { pool, fsys, fstab_config, forced_options, managed_fsys_sizes, mismount_warning },
+ actions: [
+ (fs_is_mounted
+ ? { title: _("Unmount"), action: unmount }
+ : { title: _("Mount"), action: mount }),
+ { title: _("Rename"), action: rename_fsys },
+ { title: _("Snapshot"), action: snapshot_fsys },
+ { title: _("Delete"), action: delete_fsys, danger: true },
+ ]
+ });
+}
+
+const StratisFilesystemPage = ({
+ page, pool, fsys, fstab_config, forced_options, managed_fsys_sizes, mismount_warning,
+}) => {
+ const block = client.slashdevs_block[fsys.Devnode];
+
+ return (
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+ { mismount_warning &&
+
+
+
+ }
+
+
+
+ );
+};
diff --git a/pkg/storaged/pages/stratis-pool.jsx b/pkg/storaged/pages/stratis-pool.jsx
new file mode 100644
index 000000000000..fe9ed85e0c20
--- /dev/null
+++ b/pkg/storaged/pages/stratis-pool.jsx
@@ -0,0 +1,650 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+import client from "../client";
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
+import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
+import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
+
+import { SCard } from "../utils/card.jsx";
+import { SDesc } from "../utils/desc.jsx";
+import { StorageButton, StorageUsageBar, StorageLink } from "../storage-controls.jsx";
+import { PageChildrenCard, PageCrossrefCard, ActionButtons, new_page, page_type, get_crossrefs } from "../pages.jsx";
+import {
+ fmt_size, get_active_usage, teardown_active_usage, for_each_async,
+ get_available_spaces, prepare_available_spaces,
+ reload_systemd, encode_filename, decode_filename,
+} from "../utils.js";
+import { fmt_to_fragments } from "utils.jsx";
+
+import {
+ dialog_open, SelectSpaces, TextInput, PassInput, CheckBoxes, SelectOne, SizeSlider,
+ BlockingMessage, TeardownMessage,
+ init_active_usage_processes
+} from "../dialog.jsx";
+
+import { validate_url, get_tang_adv } from "../crypto-keyslots.jsx"; // XXX
+import { is_valid_mount_point } from "../fsys-tab.jsx"; // XXX
+import { std_reply, with_keydesc, with_stored_passphrase, confirm_tang_trust, get_unused_keydesc } from "../stratis-utils.js";
+import { mount_explanation } from "../format-dialog.jsx";
+
+import { make_stratis_filesystem_page } from "./stratis-filesystem.jsx";
+
+const _ = cockpit.gettext;
+
+const fsys_min_size = 512 * 1024 * 1024;
+
+function teardown_block(block) {
+ return for_each_async(block.Configuration, c => block.RemoveConfigurationItem(c, {}));
+}
+
+export function destroy_filesystem(fsys) {
+ const block = client.slashdevs_block[fsys.Devnode];
+ const pool = client.stratis_pools[fsys.Pool];
+
+ return teardown_block(block).then(() => pool.DestroyFilesystems([fsys.path]).then(std_reply));
+}
+
+function destroy_pool(pool) {
+ return for_each_async(client.stratis_pool_filesystems[pool.path], fsys => destroy_filesystem(fsys))
+ .then(() => client.stratis_manager.DestroyPool(pool.path).then(std_reply));
+}
+
+export function validate_fs_name(fsys, name, filesystems) {
+ if (name == "")
+ return _("Name can not be empty.");
+ if (!fsys || name != fsys.Name) {
+ for (const fs of filesystems) {
+ if (fs.Name == name)
+ return _("A filesystem with this name exists already in this pool.");
+ }
+ }
+}
+
+export function validate_pool_name(pool, name) {
+ if (name == "")
+ return _("Name can not be empty.");
+ if ((!pool || name != pool.Name) && client.stratis_poolnames_pool[name])
+ return _("A pool with this name exists already.");
+}
+
+export function set_mount_options(path, vals, forced_options) {
+ let mount_options = [];
+
+ if (vals.variant == "nomount" || vals.at_boot == "never")
+ mount_options.push("noauto");
+ if (vals.mount_options.ro)
+ mount_options.push("ro");
+ if (vals.at_boot == "never")
+ mount_options.push("x-cockpit-never-auto");
+ if (vals.at_boot == "nofail")
+ mount_options.push("nofail");
+ if (vals.at_boot == "netdev")
+ mount_options.push("_netdev");
+ if (vals.mount_options.extra)
+ mount_options.push(vals.mount_options.extra);
+
+ mount_options = mount_options.concat(forced_options);
+
+ let mount_point = vals.mount_point;
+ if (mount_point == "")
+ return Promise.resolve();
+ if (mount_point[0] != "/")
+ mount_point = "/" + mount_point;
+
+ const config =
+ ["fstab",
+ {
+ dir: { t: 'ay', v: encode_filename(mount_point) },
+ type: { t: 'ay', v: encode_filename("auto") },
+ opts: { t: 'ay', v: encode_filename(mount_options.join(",") || "defaults") },
+ freq: { t: 'i', v: 0 },
+ passno: { t: 'i', v: 0 },
+ }
+ ];
+
+ function udisks_block_for_stratis_fsys() {
+ const fsys = client.stratis_filesystems[path];
+ return fsys && client.slashdevs_block[fsys.Devnode];
+ }
+
+ return client.wait_for(udisks_block_for_stratis_fsys)
+ .then(block => {
+ // HACK - need a explicit "change" event
+ return block.Rescan({})
+ .then(() => {
+ return client.wait_for(() => client.blocks_fsys[block.path])
+ .then(fsys => {
+ return block.AddConfigurationItem(config, {})
+ .then(reload_systemd)
+ .then(() => {
+ if (vals.variant != "nomount")
+ return client.mount_at(block, mount_point);
+ else
+ return Promise.resolve();
+ });
+ });
+ });
+ });
+}
+
+function create_fs(pool) {
+ const filesystems = client.stratis_pool_filesystems[pool.path];
+ const stats = client.stratis_pool_stats[pool.path];
+ const forced_options = ["x-systemd.requires=stratis-fstab-setup@" + pool.Uuid + ".service"];
+ const managed_fsys_sizes = client.features.stratis_managed_fsys_sizes && !pool.Overprovisioning;
+
+ dialog_open({
+ Title: _("Create filesystem"),
+ Fields: [
+ TextInput("name", _("Name"),
+ {
+ validate: name => validate_fs_name(null, name, filesystems)
+ }),
+ SizeSlider("size", _("Size"),
+ {
+ visible: () => managed_fsys_sizes,
+ min: fsys_min_size,
+ max: stats.pool_free,
+ round: 512
+ }),
+ TextInput("mount_point", _("Mount point"),
+ {
+ validate: (val, values, variant) => {
+ return is_valid_mount_point(client, null, val, variant == "nomount");
+ }
+ }),
+ CheckBoxes("mount_options", _("Mount options"),
+ {
+ value: {
+ ro: false,
+ extra: false
+ },
+ fields: [
+ { title: _("Mount read only"), tag: "ro" },
+ { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" },
+ ]
+ }),
+ SelectOne("at_boot", _("At boot"),
+ {
+ value: "nofail",
+ explanation: mount_explanation.nofail,
+ choices: [
+ {
+ value: "local",
+ title: _("Mount before services start"),
+ },
+ {
+ value: "nofail",
+ title: _("Mount without waiting, ignore failure"),
+ },
+ {
+ value: "netdev",
+ title: _("Mount after network becomes available, ignore failure"),
+ },
+ {
+ value: "never",
+ title: _("Do not mount"),
+ },
+ ]
+ }),
+ ],
+ update: function (dlg, vals, trigger) {
+ if (trigger == "at_boot")
+ dlg.set_options("at_boot", { explanation: mount_explanation[vals.at_boot] });
+ },
+ Action: {
+ Title: _("Create and mount"),
+ Variants: [{ tag: "nomount", Title: _("Create only") }],
+ action: function (vals) {
+ return client.stratis_create_filesystem(pool, vals.name, vals.size)
+ .then(std_reply)
+ .then(result => {
+ if (result[0])
+ return set_mount_options(result[1][0][0], vals, forced_options);
+ else
+ return Promise.resolve();
+ });
+ }
+ }
+ });
+}
+
+function delete_pool(pool) {
+ const location = cockpit.location;
+ const usage = get_active_usage(client, pool.path, _("delete"));
+
+ if (usage.Blocking) {
+ dialog_open({
+ Title: cockpit.format(_("$0 is in use"),
+ pool.Name),
+ Body: BlockingMessage(usage)
+ });
+ return;
+ }
+
+ dialog_open({
+ Title: cockpit.format(_("Permanently delete $0?"), pool.Name),
+ Teardown: TeardownMessage(usage),
+ Action: {
+ Danger: _("Deleting a Stratis pool will erase all data it contains."),
+ Title: _("Delete"),
+ action: function () {
+ return teardown_active_usage(client, usage)
+ .then(() => destroy_pool(pool))
+ .then(() => {
+ location.go('/');
+ });
+ }
+ },
+ Inits: [
+ init_active_usage_processes(client, usage)
+ ]
+ });
+}
+
+function rename_pool(pool) {
+ dialog_open({
+ Title: _("Rename Stratis pool"),
+ Fields: [
+ TextInput("name", _("Name"),
+ {
+ value: pool.Name,
+ validate: name => validate_pool_name(pool, name)
+ })
+ ],
+ Action: {
+ Title: _("Rename"),
+ action: function (vals) {
+ return pool.SetName(vals.name).then(std_reply);
+ }
+ }
+ });
+}
+
+function make_stratis_filesystem_pages(parent, pool) {
+ const filesystems = client.stratis_pool_filesystems[pool.path];
+ const stats = client.stratis_pool_stats[pool.path];
+ const forced_options = ["x-systemd.requires=stratis-fstab-setup@" + pool.Uuid + ".service"];
+ const managed_fsys_sizes = client.features.stratis_managed_fsys_sizes && !pool.Overprovisioning;
+
+ filesystems.forEach((fs, i) => make_stratis_filesystem_page(parent, pool, fs,
+ stats.fsys_offsets[i],
+ forced_options,
+ managed_fsys_sizes));
+}
+
+export function make_stratis_pool_page(parent, pool) {
+ const degraded_ops = pool.AvailableActions && pool.AvailableActions !== "fully_operational";
+ const blockdevs = client.stratis_pool_blockdevs[pool.path] || [];
+ const can_grow =
+ (client.features.stratis_grow_blockdevs &&
+ blockdevs.some(bd => bd.NewPhysicalSize[0] && Number(bd.NewPhysicalSize[1]) > Number(bd.TotalPhysicalSize)));
+
+ const p = new_page({
+ location: ["pool", pool.Uuid],
+ parent,
+ name: pool.Name,
+ columns: [
+ pool.Encrypted ? _("Encrypted Stratis pool") : _("Stratis pool"),
+ "/dev/stratis/" + pool.Name + "/",
+ fmt_size(pool.TotalPhysicalSize),
+ ],
+ has_warning: degraded_ops || can_grow,
+ component: StratisPoolPage,
+ props: { pool, degraded_ops, can_grow },
+ actions: [
+ { title: _("Rename"), action: () => rename_pool(pool), },
+ { title: _("Delete"), action: () => delete_pool(pool), danger: true },
+ ],
+ });
+
+ make_stratis_filesystem_pages(p, pool);
+}
+
+const StratisPoolPage = ({ page, pool, degraded_ops, can_grow }) => {
+ const key_desc = (pool.Encrypted &&
+ pool.KeyDescription[0] &&
+ pool.KeyDescription[1][1]);
+ const can_tang = (client.features.stratis_crypto_binding &&
+ pool.Encrypted &&
+ pool.ClevisInfo[0] && // pool has consistent clevis config
+ (!pool.ClevisInfo[1][0] || pool.ClevisInfo[1][1][0] == "tang")); // not bound or bound to "tang"
+ const tang_url = can_tang && pool.ClevisInfo[1][0] ? JSON.parse(pool.ClevisInfo[1][1][1]).url : null;
+ const blockdevs = client.stratis_pool_blockdevs[pool.path] || [];
+ const managed_fsys_sizes = client.features.stratis_managed_fsys_sizes && !pool.Overprovisioning;
+ const stats = client.stratis_pool_stats[pool.path];
+
+ function grow_blockdevs() {
+ return for_each_async(blockdevs, bd => pool.GrowPhysicalDevice(bd.Uuid));
+ }
+
+ const alerts = [];
+ if (can_grow) {
+ alerts.push(
+
+ {_("Some block devices of this pool have grown in size after the pool was created. The pool can be safely grown to use the newly available space.")}
+