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 1562ea38ac99..850cc04c8d8d 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) @@ -566,6 +587,7 @@ function update_indices() { client.update = () => { update_indices(); client.path_warnings = find_warnings(client); + create_pages(); client.dispatchEvent("changed"); }; @@ -844,11 +866,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..1ad5e81eace6 --- /dev/null +++ b/pkg/storaged/containers/encryption.jsx @@ -0,0 +1,264 @@ +/* + * 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, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; +import { Card, CardHeader, CardTitle, 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 { 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 ( + + + {_("Encryption")} + + + + + {_("Encryption type")} + + { luks_info.luks_version ? "LUKS" + luks_info.luks_version : "-" } + + + + {_("Cleartext device")} + + {cleartext ? block_name(cleartext) : "-"} + + + + {_("Stored passphrase")} + + + { passphrase_path ? stored_passphrase_info.mtime || _("yes") : _("none") } + {_("edit")} + + + + + {_("Options")} + + + { 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..045711f4a75e --- /dev/null +++ b/pkg/storaged/containers/lvm2-logical-volume.jsx @@ -0,0 +1,220 @@ +/* + * 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 { 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 { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; +import { StorageButton, StorageLink } from "../storage-controls.jsx"; + +import { check_unused_space, get_resize_info, grow_dialog, shrink_dialog } from "../resize.jsx"; +import { new_container, 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, SelectSpaces, +} from "../dialog.jsx"; + +import { lvol_rename, 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))}

+ }); + return; + } + + function enough_space(pvs) { + const selected = pvs.reduce((sum, pv) => sum + pv.size, 0); + if (selected < missing) + return cockpit.format(_("An additional $0 must be selected"), fmt_size(missing - selected)); + } + + dialog_open({ + Title: cockpit.format(_("Repair logical volume $0"), lvol.Name), + Body:

{cockpit.format(_("Select the physical volumes that should be used to repair the logical volume. At leat $0 are needed."), + fmt_size(missing))}


, + Fields: [ + SelectSpaces("pvs", _("Physical Volumes"), + { + spaces: pvs_as_spaces, + validate: enough_space + }), + ], + Action: { + Title: _("Repair"), + action: function (vals) { + return lvol.Repair(vals.pvs.map(spc => spc.block.path), { }); + } + } + }); +} + +function repair_action(lvol) { + const status_code = client.lvols_status[lvol.path]; + + if (status_code == "degraded" || status_code == "degraded-maybe-partial") + return { title: _("Repair"), action: () => repair }; + else + return null; +} + +export function make_lvm2_logical_volume_container(parent, vgroup, lvol, block) { + const unused_space_warning = check_unused_space(block.path); + + return new_container({ + page_name: lvol.Name, + page_location: ["vg", vgroup.Name, lvol.Name], + stored_on_format: _("Logical volume of $0"), + has_warning: !!unused_space_warning, + component: LVM2LogicalVolumeContainer, + props: { vgroup, lvol, block, unused_space_warning }, + actions: [ + { title: _("Deactivate"), action: () => lvol.Deactivate({}) }, + lvm2_create_snapshot_action(lvol), + repair_action(lvol), + { title: _("Delete"), action: () => lvm2_delete_logical_volume_dialog(lvol), danger: true }, + ], + }); +} + +const LVM2LogicalVolumeContainer = ({ container, vgroup, lvol, block, unused_space_warning }) => { + const pool = client.lvols[lvol.ThinPool]; + const unused_space = !!unused_space_warning; + + let { info, shrink_excuse, grow_excuse } = get_resize_info(client, block, unused_space); + + if (!unused_space && !grow_excuse && !pool && vgroup.FreeSize == 0) { + grow_excuse = ( +
+ {_("Not enough space to grow.")} +
+ {_("Free up space in this group: Shrink or delete other logical volumes or add another physical volume.")} +
+ ); + } + + function shrink() { + return shrink_dialog(client, lvol, info, unused_space); + } + + function grow() { + return grow_dialog(client, lvol, info, unused_space); + } + + const layout_desc = { + raid0: _("Striped (RAID 0)"), + raid1: _("Mirrored (RAID 1)"), + raid10: _("Striped and mirrored (RAID 10)"), + raid4: _("Dedicated parity (RAID 4)"), + raid5: _("Distributed parity (RAID 5)"), + raid6: _("Double distributed parity (RAID 6)") + }; + + const layout = lvol.Layout; + + return ( + + }}> + {_("Logical volume")} + + + + + {_("Name")} + + + {lvol.Name} + + lvol_rename(lvol)}> + {_("edit")} + + + + + + { (layout && layout != "linear") && + + {_("Layout")} + + + {layout_desc[layout] || layout} + + + + } + + { !unused_space && + + {_("Size")} + + {fmt_size(lvol.Size)} +
+ {_("Shrink")} + {_("Grow")} +
+
+
+ } +
+ { unused_space && + <> +
+ + {cockpit.format(_("Volume size is $0. Content size is $1."), + fmt_size(unused_space_warning.volume_size), + fmt_size(unused_space_warning.content_size))} +
+ {_("Shrink volume")} + {_("Grow content")} +
+
+ + } +
+
); +}; diff --git a/pkg/storaged/containers/partition.jsx b/pkg/storaged/containers/partition.jsx new file mode 100644 index 000000000000..4272504004fe --- /dev/null +++ b/pkg/storaged/containers/partition.jsx @@ -0,0 +1,144 @@ +/* + * 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 { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { dialog_open, init_active_usage_processes, BlockingMessage, TeardownMessage } from "../dialog.jsx"; +import { StorageButton } from "../storage-controls.jsx"; +import { block_name, fmt_size, get_active_usage, teardown_active_usage, reload_systemd } from "../utils.js"; +import { check_unused_space, get_resize_info, free_space_after_part, grow_dialog, shrink_dialog } from "../resize.jsx"; +import { new_container, ActionButtons } from "../pages.jsx"; + +const _ = cockpit.gettext; + +export function make_partition_container(parent, block) { + const unused_space_warning = check_unused_space(block.path); + + function delete_() { + const block_part = client.blocks_part[block.path]; + const name = block_name(block); + const usage = get_active_usage(client, block.path, _("delete")); + + if (usage.Blocking) { + dialog_open({ + Title: cockpit.format(_("$0 is in use"), name), + Body: BlockingMessage(usage) + }); + return; + } + + dialog_open({ + Title: cockpit.format(_("Permanently delete $0?"), name), + Teardown: TeardownMessage(usage), + Action: { + Danger: _("Deleting a partition will delete all data in it."), + Title: _("Delete"), + action: function () { + return teardown_active_usage(client, usage) + .then(() => block_part.Delete({ 'tear-down': { t: 'b', v: true } })) + .then(reload_systemd); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); + } + + return new_container({ + stored_on_format: _("Partition of $0"), + has_warning: !!unused_space_warning, + component: PartitionContainer, + props: { block, unused_space_warning }, + actions: [ + { title: _("Delete"), action: delete_, danger: true }, + ], + }); +} + +const PartitionContainer = ({ container, block, unused_space_warning }) => { + const block_part = client.blocks_part[block.path]; + const unused_space = !!unused_space_warning; + + let { info, shrink_excuse, grow_excuse } = get_resize_info(client, block, unused_space); + + if (!unused_space && !grow_excuse && free_space_after_part(client, block_part) == 0) { + grow_excuse = _("No free space after this partition"); + } + + function shrink() { + return shrink_dialog(client, block_part, info, unused_space); + } + + function grow() { + return grow_dialog(client, block_part, info, unused_space); + } + + return ( + }> + + + + { !unused_space && + + {fmt_size(block_part.Size)} +
+ + {_("Shrink")} + + + {_("Grow")} + +
+
+ } + + +
+ { unused_space && + <> +
+ + {cockpit.format(_("Partition size is $0. Content size is $1."), + fmt_size(unused_space_warning.volume_size), + fmt_size(unused_space_warning.content_size))} +
+ + {_("Shrink partition")} + + + {_("Grow content")} + +
+
+ + } +
+
); +}; diff --git a/pkg/storaged/content-views.jsx b/pkg/storaged/content-views.jsx index 2489c12662eb..aea8e65f0ac3 100644 --- a/pkg/storaged/content-views.jsx +++ b/pkg/storaged/content-views.jsx @@ -54,7 +54,7 @@ const _ = cockpit.gettext; const C_ = cockpit.gettext; -function next_default_logical_volume_name(client, vgroup, prefix) { +export function next_default_logical_volume_name(client, vgroup, prefix) { function find_lvol(name) { const lvols = client.vgroups_lvols[vgroup.path]; for (let i = 0; i < lvols.length; i++) { @@ -714,7 +714,7 @@ export function block_content_rows(client, block, options) { return rows; } -function format_disk(client, block) { +export function format_disk(client, block) { const usage = utils.get_active_usage(client, block.path, _("initialize"), _("delete")); if (usage.Blocking) { @@ -890,7 +890,7 @@ function install_package(name, progress) { }); } -function create_logical_volume(client, vgroup) { +export function create_logical_volume(client, vgroup) { if (vgroup.FreeSize == 0) return; diff --git a/pkg/storaged/create-pages.jsx b/pkg/storaged/create-pages.jsx new file mode 100644 index 000000000000..30949f9f58dd --- /dev/null +++ b/pkg/storaged/create-pages.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 client from "./client"; + +import { get_partitions, fmt_size } from "./utils.js"; +import { get_fstab_config } from "./fsys-tab.jsx"; // XXX + +import { format_dialog } from "./format-dialog.jsx"; + +import { make_overview_page } from "./pages/overview.jsx"; +import { make_unrecognized_data_page } from "./pages/unrecognized-data.jsx"; +import { make_locked_encrypted_data_page } from "./pages/locked-encrypted-data.jsx"; +import { make_filesystem_page } from "./pages/filesystem.jsx"; +import { make_lvm2_physical_volume_page } from "./pages/lvm2-physical-volume.jsx"; +import { make_mdraid_disk_page } from "./pages/mdraid-disk.jsx"; +import { make_stratis_blockdev_page } from "./pages/stratis-blockdev.jsx"; + +import { make_partition_container } from "./containers/partition.jsx"; +import { make_encryption_container } from "./containers/encryption.jsx"; + +import { new_page, reset_pages } from "./pages.jsx"; + +const _ = cockpit.gettext; + +/* Creating all the pages + * + * This is where a lot of the hair is. + */ + +export function make_block_pages(parent, block) { + if (client.blocks_ptable[block.path]) + make_partition_pages(parent, block); + else + make_block_page(parent, block, null); +} + +function make_partition_pages(parent, block) { + const block_ptable = client.blocks_ptable[block.path]; + + function make_free_space_page(parent, start, size, enable_dos_extended) { + new_page({ + parent, + name: _("Free space"), + columns: [ + null, + null, + fmt_size(size), + ], + actions: [ + { + title: _("Create partition"), + action: () => format_dialog(client, block.path, start, size, + enable_dos_extended), + } + ], + }); + } + + function make_extended_partition_page(parent, partition) { + const page = new_page({ + parent, + name: _("Extended partition") + }); + process_partitions(page, partition.partitions, false); + } + + function process_partitions(parent, partitions, enable_dos_extended) { + let i, p; + for (i = 0; i < partitions.length; i++) { + p = partitions[i]; + if (p.type == 'free') + make_free_space_page(parent, p.start, p.size, enable_dos_extended); + else if (p.type == 'container') + make_extended_partition_page(parent, p); + else { + const container = make_partition_container(null, p.block); + make_block_page(parent, p.block, container); + } + } + } + + process_partitions(parent, get_partitions(client, block), + block_ptable.Type == 'dos'); +} + +export function make_block_page(parent, block, container) { + let is_crypto = block.IdUsage == 'crypto'; + let content_block = is_crypto ? client.blocks_cleartext[block.path] : block; + const fstab_config = get_fstab_config(content_block || block, true); + + const block_stratis_blockdev = client.blocks_stratis_blockdev[block.path]; + const block_stratis_stopped_pool = client.blocks_stratis_stopped_pool[block.path]; + + const is_stratis = ((content_block && content_block.IdUsage == "raid" && content_block.IdType == "stratis") || + (block_stratis_blockdev && client.stratis_pools[block_stratis_blockdev.Pool]) || + block_stratis_stopped_pool); + + // Adjust for encryption leaking out of Stratis + if (is_crypto && is_stratis) { + is_crypto = false; + content_block = block; + } + + if (is_crypto) + container = make_encryption_container(container, block); + + if (!content_block) { + // assert(is_crypto); + if (fstab_config.length > 0) { + make_filesystem_page(parent, block, null, fstab_config, container); + } else { + make_locked_encrypted_data_page(parent, block, container); + } + return; + } + + const is_filesystem = content_block.IdUsage == 'filesystem'; + const block_pvol = client.blocks_pvol[content_block.path]; + + if (is_filesystem) { + make_filesystem_page(parent, block, content_block, fstab_config, container); + } else if ((content_block.IdUsage == "raid" && content_block.IdType == "LVM2_member") || + (block_pvol && client.vgroups[block_pvol.VolumeGroup])) { + make_lvm2_physical_volume_page(parent, block, content_block, container); + } else if (is_stratis) { + make_stratis_blockdev_page(parent, block, content_block, container); + } else if ((content_block.IdUsage == "raid") || + (client.mdraids[content_block.MDRaidMember])) { + make_mdraid_disk_page(parent, block, content_block, container); + } else { + make_unrecognized_data_page(parent, block, content_block, container); + } +} + +export function create_pages() { + reset_pages(); + make_overview_page(); +} diff --git a/pkg/storaged/crypto-keyslots.jsx b/pkg/storaged/crypto-keyslots.jsx index 358ea47f6c84..10a327b3a96f 100644 --- a/pkg/storaged/crypto-keyslots.jsx +++ b/pkg/storaged/crypto-keyslots.jsx @@ -20,7 +20,7 @@ import cockpit from "cockpit"; import React from "react"; -import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js'; +import { CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js'; import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js"; import { ClipboardCopy } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js"; import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js"; @@ -873,7 +873,7 @@ export class CryptoKeyslots extends React.Component { const remaining = max_slots - keys.length; return ( - + <> @@ -895,7 +895,7 @@ export class CryptoKeyslots extends React.Component { {rows} - + ); } } diff --git a/pkg/storaged/dialog.jsx b/pkg/storaged/dialog.jsx index d861fc7ed324..ec97684d52be 100644 --- a/pkg/storaged/dialog.jsx +++ b/pkg/storaged/dialog.jsx @@ -1079,11 +1079,13 @@ export const SizeSlider = (tag, title, options) => { export const BlockingMessage = (usage) => { const usage_desc = { pvol: _("physical volume of LVM2 volume group"), - mdraid: _("member of RAID device"), + "mdraid-member": _("member of RAID device"), vdo: _("backing device for VDO device"), "stratis-pool-member": _("member of Stratis pool") }; + console.log("U", usage); + const rows = []; usage.forEach(use => { if (use.blocking && use.block) { diff --git a/pkg/storaged/iscsi-panel.jsx b/pkg/storaged/iscsi-panel.jsx index 8a97649a20bb..84675792109f 100644 --- a/pkg/storaged/iscsi-panel.jsx +++ b/pkg/storaged/iscsi-panel.jsx @@ -30,7 +30,7 @@ import { dialog_open, TextInput, PassInput, SelectRow } from "./dialog.jsx"; const _ = cockpit.gettext; -function iscsi_discover(client) { +export function iscsi_discover(client) { dialog_open({ Title: _("Add iSCSI portal"), Fields: [ @@ -164,7 +164,7 @@ function iscsi_add_with_creds(client, discover_vals, login_vals) { }); } -function iscsi_change_name(client) { +export function iscsi_change_name(client) { return client.manager_iscsi.call('GetInitiatorName') .then(function (results) { const name = results[0]; diff --git a/pkg/storaged/lvol-tabs.jsx b/pkg/storaged/lvol-tabs.jsx index 0c97644fb53f..ed2181d7020a 100644 --- a/pkg/storaged/lvol-tabs.jsx +++ b/pkg/storaged/lvol-tabs.jsx @@ -41,7 +41,7 @@ export function check_partial_lvols(client, path, enter_warning) { } } -function lvol_rename(lvol) { +export function lvol_rename(lvol) { dialog_open({ Title: _("Rename logical volume"), Fields: [ @@ -57,7 +57,7 @@ function lvol_rename(lvol) { }); } -const StructureDescription = ({ client, lvol }) => { +export const StructureDescription = ({ client, lvol }) => { const struct = lvol.Structure; if (!struct) diff --git a/pkg/storaged/overview.jsx b/pkg/storaged/overview.jsx deleted file mode 100644 index eeab02bf1ce5..000000000000 --- a/pkg/storaged/overview.jsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2017 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 React from "react"; - -import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js"; -import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; -import { Card, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; -import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; - -import { StoragePlots } from "./plot.jsx"; - -import { FilesystemsPanel } from "./fsys-panel.jsx"; -import { LockedCryptoPanel } from "./crypto-panel.jsx"; -import { NFSPanel } from "./nfs-panel.jsx"; -import { ThingsPanel } from "./things-panel.jsx"; -import { IscsiPanel } from "./iscsi-panel.jsx"; -import { DrivesPanel } from "./drives-panel.jsx"; -import { OthersPanel } from "./others-panel.jsx"; - -import { JobsPanel } from "./jobs-panel.jsx"; -import { StorageLogsPanel } from "./logs-panel.jsx"; - -export const Overview = ({ client, plot_state }) => { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/pkg/storaged/pages.jsx b/pkg/storaged/pages.jsx new file mode 100644 index 000000000000..cbbb1683e6b0 --- /dev/null +++ b/pkg/storaged/pages.jsx @@ -0,0 +1,366 @@ +/* + * 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 { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; +import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; +import { ListingTable } from "cockpit-components-table.jsx"; +import { DropdownSeparator } from '@patternfly/react-core/dist/esm/deprecated/components/Dropdown/index.js'; +import { ExclamationTriangleIcon } from "@patternfly/react-icons"; + +import { SCard } from "./utils/card.jsx"; +import { decode_filename } from "./utils.js"; +import { fmt_to_fragments } from "utils.jsx"; + +import { StorageButton, StorageBarMenu, StorageMenuItem } from "./storage-controls.jsx"; + +const _ = cockpit.gettext; + +let pages = null; +let crossrefs = null; + +export function reset_pages() { + pages = new Map(); + crossrefs = new Map(); +} + +function name_from_container(container) { + if (!container) + return null; + if (container.page_name) + return container.page_name; + return name_from_container(container.parent); +} + +function location_from_container(container) { + if (!container) + return null; + if (container.page_location) + return container.page_location; + return location_from_container(container.parent); +} + +export function new_page({ + location, parent, container, + name, component, props, columns, has_warning, actions +}) { + const loc = location_from_container(container) || location; + const page = { + location: loc, + name: name_from_container(container) || name, + parent, + component, + props: props || {}, + children: [], + container, + columns: columns || [], + has_warning, + actions: actions ? actions.filter(a => !!a) : null, + }; + if (parent) + parent.children.push(page); + if (loc) { + pages.set(JSON.stringify(loc), page); + if (loc.length == 0) { + // This is the Overview page. Make it the parent of the + // special "not found" page (but don't make the "not + // found" page a child of the Overview...) + not_found_page.parent = page; + } + } + return page; +} + +export function new_container({ + parent, + type_format, stored_on_format, page_name, page_location, + component, props, + has_warning, actions +}) { + return { + parent, + type_format, + stored_on_format, + page_name, + page_location, + component, + props, + has_warning, + actions: actions ? actions.filter(a => !!a) : null, + }; +} + +export function register_crossref(crossref) { + const val = crossrefs.get(crossref.key) || []; + val.push(crossref); + crossrefs.set(crossref.key, val); +} + +export function get_crossrefs(key) { + return crossrefs.get(key); +} + +/* Getting the page for a navigation location. + * + * We have a special "not found" page that is returned when there is + * no real page at the given location. + */ + +const NotFoundPage = ({ page }) => { + return {_("Not found")}; +}; + +const not_found_page = new_page({ + name: "Not found", + component: NotFoundPage +}); + +export function get_page_from_location(location) { + if (!pages) + return not_found_page; + + return pages.get(JSON.stringify(location)) || not_found_page; +} + +function make_menu_item(action) { + return + {action.title} + ; +} + +function make_page_kebab(page) { + const items = []; + + function add_actions(actions) { + if (!actions) + return; + if (items.length > 0) + items.push(); + for (const a of actions) + items.push(make_menu_item(a)); + } + + add_actions(page.actions); + let cont = page.container; + while (cont) { + add_actions(cont.actions); + cont = cont.parent; + } + + if (items.length == 0) + return null; + + return ; +} + +function make_actions_kebab(actions) { + if (actions.length == 0) + return null; + + return ; +} + +export const ActionButtons = ({ page, container }) => { + const actions = page ? page.actions : container.actions; + if (!actions) + return null; + + return actions.map(a => + + {a.title} + ); +}; + +export function page_type(page) { + let type = page.columns[0]; + + let cont = page.container; + while (cont) { + if (cont.type_format) + type = fmt_to_fragments(cont.type_format, type); + cont = cont.parent; + } + + return type; +} + +export function page_stored_on(page) { + const pp = page.parent; + + let text = pp.name; + let cont = page.container; + while (cont) { + if (cont.stored_on_format) + text = fmt_to_fragments(cont.stored_on_format, text); + cont = cont.parent; + } + + return text; +} + +const PageTable = ({ emptyCaption, aria_label, pages, crossrefs }) => { + const rows = []; + + function container_has_warning(container) { + if (container) + return container.has_warning || container_has_warning(container.parent); + else + return false; + } + + function make_row(page, crossref, level) { + let info = null; + if (page.has_warning || container_has_warning(page.container)) + info = <>{"\n"}; + const type_colspan = page.columns[1] ? 1 : 2; + const cols = [ + { title: {page.name}{info} }, + { + title: crossref ? page_stored_on(page) : page_type(page), + props: { colSpan: type_colspan }, + }, + ]; + if (type_colspan == 1) + cols.push({ title: crossref ? null : page.columns[1] }); + cols.push({ + title: crossref ? crossref.size : page.columns[2], + props: { className: "pf-v5-u-text-align-right" } + }); + cols.push({ + title: crossref ? make_actions_kebab(crossref.actions) : make_page_kebab(page), + props: { className: "pf-v5-c-table__action content-action" } + }); + + return { + props: { + key: page.name, + className: "content-level-" + level, + "data-test-row-name": page.name, + "data-test-row-location": page.columns[1], + }, + columns: cols, + go: () => { + if (page.location) + cockpit.location.go(page.location); + } + }; + } + + function make_page_rows(pages, level) { + for (const p of pages) { + rows.push(make_row(p, null, level)); + make_page_rows(p.children, level + 1); + } + } + + function make_crossref_rows(crossrefs) { + for (const c of crossrefs) { + rows.push(make_row(c.page, c, 0)); + } + } + + if (pages) + make_page_rows(pages, 0); + else if (crossrefs) + make_crossref_rows(crossrefs); + + function onRowClick(event, row) { + if (!event || event.button !== 0) + return; + + // StorageBarMenu sets this to tell us not to navigate when + // the kebabs are opened. + if (event.defaultPrevented) + return; + + if (row.go) + row.go(); + } + + return ; +}; + +export const PageChildrenCard = ({ title, page, emptyCaption, actions }) => { + return ( + + + + + ); +}; + +export const PageCrossrefCard = ({ title, crossrefs, emptyCaption, actions }) => { + return ( + + + + + ); +}; + +export const ParentPageLink = ({ page }) => { + const pp = page.parent; + + let link = ( + ); + + let cont = page.container; + while (cont) { + if (cont.stored_on_format) + link = fmt_to_fragments(cont.stored_on_format, link); + cont = cont.parent; + } + + return link; +}; + +export const Container = ({ container }) => { + return ; +}; + +export const PageContainerStackItems = ({ page }) => { + const items = []; + let cont = page.container; + while (cont) { + items.push(); + cont = cont.parent; + } + return items; +}; + +export function block_location(block) { + return decode_filename(block.PreferredDevice).replace(/^\/dev\//, ""); +} diff --git a/pkg/storaged/pages/drive.jsx b/pkg/storaged/pages/drive.jsx new file mode 100644 index 000000000000..46b75e48fcdf --- /dev/null +++ b/pkg/storaged/pages/drive.jsx @@ -0,0 +1,127 @@ +/* + * 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 { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { StorageButton } from "../storage-controls.jsx"; +import { PageChildrenCard, ParentPageLink, new_page, page_type, block_location } from "../pages.jsx"; +import { block_name, drive_name, format_temperature, fmt_size, fmt_size_long } 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_drive_page(parent, drive) { + const block = client.drives_block[drive.path]; + if (!block) + return; + + const drive_page = new_page({ + location: ["drive", block_location(block)], + parent, + name: drive_name(drive), + columns: [ + _("Drive"), + block_name(block), + fmt_size(block.Size) + ], + component: DrivePage, + props: { drive, block } + }); + + make_block_pages(drive_page, block, null); +} + +const DrivePage = ({ page, drive, block }) => { + const drive_ata = client.drives_ata[drive.path]; + const multipath_blocks = drive && client.drives_multipath_blocks[drive.path]; + const is_partitioned = !!client.blocks_ptable[block.path]; + + let assessment = null; + if (drive_ata) { + assessment = ( + + + { drive_ata.SmartFailing + ? {_("Disk is failing")} + : {_("Disk is OK")} + } + { drive_ata.SmartTemperature > 0 + ? ({format_temperature(drive_ata.SmartTemperature)}) + : null + } + + ); + } + + const actions = + format_disk(client, block)} + excuse={block.ReadOnly ? _("Device is read-only") : null}> + {_("Create partition table")} + ; + + return ( + + + + + + { client.drives_iscsi_session[drive.path] + ? + + + : null } + + + + + + {drive.Size + ? fmt_size_long(drive.Size) + : _("No media inserted") + } + + { assessment } + + { multipath_blocks.length > 0 && + + } + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/filesystem.jsx b/pkg/storaged/pages/filesystem.jsx new file mode 100644 index 000000000000..709f345bc25b --- /dev/null +++ b/pkg/storaged/pages/filesystem.jsx @@ -0,0 +1,435 @@ +/* + * 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 { 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 { 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 { useEvent } from "hooks"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { + dialog_open, TextInput, +} from "../dialog.jsx"; +import { StorageButton, StorageLink, StorageUsageBar } from "../storage-controls.jsx"; +import { + ParentPageLink, PageContainerStackItems, + new_page, block_location, ActionButtons, page_type, +} from "../pages.jsx"; +import { format_dialog } from "../format-dialog.jsx"; +import { is_mounted, mounting_dialog, get_cryptobacking_noauto } from "../fsys-tab.jsx"; // XXX +import { + block_name, fmt_size, parse_options, unparse_options, extract_option, + set_crypto_auto_option, + encode_filename, decode_filename, reload_systemd, validate_fsys_label +} from "../utils.js"; + +const _ = cockpit.gettext; + +/* This page is used in a variety of cases, which can be distinguished + * by looking at the "backing_block" and "content_block" parameters: + * + * not-encrypted: backing_block == content_block, + * content_block != null + * + * encrypted and unlocked: backing_block != content_block, + * backing_block != null, + * content_block != null + * + * encrypted and locked: backing_block != null, + * content_block == null + * + * "backing_block" is always non-null and always refers to the block + * device that we want to talk about in the UI. "content_block" (when + * non-null) is the block that we need to use for filesystem related + * actions, such as mounting. It's the one with the + * "o.fd.UDisks2.Filesystem" interface. + * + * When "content_block" is null, then "backing_block" is a locked LUKS + * device, but we could figure out the fstab entry for the filesystem + * that's on it. + */ + +export function check_mismounted_fsys(backing_block, content_block, fstab_config) { + const block_fsys = content_block && client.blocks_fsys[content_block.path]; + const [, dir, opts] = fstab_config; + + if (!(block_fsys || dir)) + return; + + const mounted_at = block_fsys ? block_fsys.MountPoints.map(decode_filename) : []; + const split_options = parse_options(opts); + const opt_noauto = extract_option(split_options, "noauto"); + const opt_noauto_intent = extract_option(split_options, "x-cockpit-never-auto"); + const opt_systemd_automount = split_options.indexOf("x-systemd.automount") >= 0; + const is_mounted = mounted_at.indexOf(dir) >= 0; + const other_mounts = mounted_at.filter(m => m != dir); + const crypto_backing_noauto = get_cryptobacking_noauto(client, backing_block); + + let type; + if (dir) { + if (!is_mounted && other_mounts.length > 0) { + if (!opt_noauto) + type = "change-mount-on-boot"; + else + type = "mounted-no-config"; + } else if (crypto_backing_noauto && !opt_noauto) + type = "locked-on-boot-mount"; + else if (!is_mounted && !opt_noauto) + type = "mount-on-boot"; + else if (is_mounted && opt_noauto && !opt_noauto_intent && !opt_systemd_automount) + type = "no-mount-on-boot"; + } else if (other_mounts.length > 0) { + // We don't complain about the rootfs, it's probably + // configured somewhere else, like in the bootloader. + if (other_mounts[0] != "/") + type = "mounted-no-config"; + } + + if (type) + return { warning: "mismounted-fsys", type, other: other_mounts[0] }; +} + +const MountPointUsageBar = ({ mount_point, block }) => { + useEvent(client.fsys_sizes, "changed"); + const stats = client.fsys_sizes.data[mount_point]; + if (stats) + return ; + else + return fmt_size(block.Size); +}; + +export function make_filesystem_page(parent, backing_block, content_block, fstab_config, container) { + const [, mount_point] = fstab_config; + const name = block_name(backing_block); + const mismount_warning = check_mismounted_fsys(backing_block, content_block, fstab_config); + const mounted = content_block && is_mounted(client, content_block); + + let mp_text; + if (mount_point && mounted) + mp_text = mount_point; + else if (mount_point && !mounted) + mp_text = mount_point + " " + _("(not mounted)"); + else + mp_text = _("(not mounted)"); + + new_page({ + location: [block_location(backing_block)], + parent, + container, + name, + columns: [ + content_block ? cockpit.format(_("$0 filesystem"), content_block.IdType) : _("Filesystem"), + mp_text, + , + ], + has_warning: !!mismount_warning, + component: FilesystemPage, + props: { backing_block, content_block, fstab_config, mismount_warning }, + actions: [ + content_block && mounted + ? { title: _("Unmount"), action: () => mounting_dialog(client, content_block, "unmount") } + : { title: _("Mount"), action: () => mounting_dialog(client, content_block || backing_block, "mount") }, + { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + ] + }); +} + +export const MountPoint = ({ fstab_config, forced_options, backing_block, content_block }) => { + const is_filesystem_mounted = content_block && is_mounted(client, content_block); + const [, old_dir, old_opts] = fstab_config; + const split_options = parse_options(old_opts); + extract_option(split_options, "noauto"); + const opt_ro = extract_option(split_options, "ro"); + const opt_never_auto = extract_option(split_options, "x-cockpit-never-auto"); + const opt_nofail = extract_option(split_options, "nofail"); + const opt_netdev = extract_option(split_options, "_netdev"); + if (forced_options) + for (const opt of forced_options) + extract_option(split_options, opt); + + let mount_point_text = null; + if (old_dir) { + let opt_texts = []; + if (opt_ro) + opt_texts.push(_("read only")); + if (opt_never_auto) + opt_texts.push(_("never mount at boot")); + else if (opt_netdev) + opt_texts.push(_("after network")); + else if (opt_nofail) + opt_texts.push(_("ignore failure")); + else + opt_texts.push(_("stop boot on failure")); + opt_texts = opt_texts.concat(split_options); + if (opt_texts.length) { + mount_point_text = cockpit.format("$0 ($1)", old_dir, opt_texts.join(", ")); + } else { + mount_point_text = old_dir; + } + } + + let extra_text = null; + if (!is_filesystem_mounted) { + if (!old_dir) + extra_text = _("The filesystem has no permanent mount point."); + else + extra_text = _("The filesystem is not mounted."); + } else if (backing_block != content_block) { + if (!opt_never_auto) + extra_text = _("The filesystem will be unlocked and mounted on the next boot. This might require inputting a passphrase."); + } + + if (extra_text && mount_point_text) + extra_text = <>
{extra_text}; + + return ( + <> + { mount_point_text && + + { mount_point_text } + + mounting_dialog(client, + content_block || backing_block, + "update", + forced_options)}> + {_("edit")} + + + + } + { extra_text } + ); +}; + +export const MismountAlert = ({ warning, fstab_config, forced_options, backing_block, content_block }) => { + if (!warning) + return null; + + const { type, other } = warning; + const [old_config, old_dir, old_opts, old_parents] = fstab_config; + const split_options = parse_options(old_opts); + extract_option(split_options, "noauto"); + const opt_ro = extract_option(split_options, "ro"); + const opt_nofail = extract_option(split_options, "nofail"); + const opt_netdev = extract_option(split_options, "_netdev"); + const split_options_for_fix_config = split_options.slice(); + if (forced_options) + for (const opt of forced_options) + extract_option(split_options, opt); + + function fix_config() { + let opts = []; + if (type == "mount-on-boot") + opts.push("noauto"); + if (type == "locked-on-boot-mount") { + opts.push("noauto"); + opts.push("x-cockpit-never-auto"); + } + if (opt_ro) + opts.push("ro"); + if (opt_nofail) + opts.push("nofail"); + if (opt_netdev) + opts.push("_netdev"); + + // Add the forced options, but only to new entries. We + // don't want to modify existing entries beyond what we + // say on the button. + if (!old_config && forced_options) + opts = opts.concat(forced_options); + + const new_opts = unparse_options(opts.concat(split_options_for_fix_config)); + let all_new_opts; + if (new_opts && old_parents) + all_new_opts = new_opts + "," + old_parents; + else if (new_opts) + all_new_opts = new_opts; + else + all_new_opts = old_parents; + + let new_dir = old_dir; + if (type == "change-mount-on-boot" || type == "mounted-no-config") + new_dir = other; + + const new_config = [ + "fstab", { + fsname: old_config ? old_config[1].fsname : undefined, + dir: { t: 'ay', v: encode_filename(new_dir) }, + type: { t: 'ay', v: encode_filename("auto") }, + opts: { t: 'ay', v: encode_filename(all_new_opts || "defaults") }, + freq: { t: 'i', v: 0 }, + passno: { t: 'i', v: 0 }, + "track-parents": { t: 'b', v: !old_config } + }]; + + function fixup_crypto_backing() { + if (!backing_block) + return; + if (type == "no-mount-on-boot") + return set_crypto_auto_option(backing_block, true); + if (type == "locked-on-boot-mount") + return set_crypto_auto_option(backing_block, false); + } + + function fixup_fsys() { + if (old_config) + return backing_block.UpdateConfigurationItem(old_config, new_config, {}).then(reload_systemd); + else + return backing_block.AddConfigurationItem(new_config, {}).then(reload_systemd); + } + + return fixup_fsys().then(fixup_crypto_backing); + } + + function fix_mount() { + const crypto_backing_crypto = client.blocks_crypto[backing_block.path]; + + function do_mount() { + if (!content_block) + mounting_dialog(client, backing_block, "mount", forced_options); + else + return client.mount_at(content_block, old_dir); + } + + function do_unmount() { + return client.unmount_at(old_dir) + .then(() => { + if (backing_block != content_block) + return crypto_backing_crypto.Lock({}); + }); + } + + if (type == "change-mount-on-boot") + return client.unmount_at(other).then(() => client.mount_at(content_block, old_dir)); + else if (type == "mount-on-boot") + return do_mount(); + else if (type == "no-mount-on-boot") + return do_unmount(); + else if (type == "mounted-no-config") + return do_unmount(); + else if (type == "locked-on-boot-mount") { + if (backing_block != content_block) + return set_crypto_auto_option(backing_block, true); + } + } + + let text; + let fix_config_text; + let fix_mount_text; + + if (type == "change-mount-on-boot") { + text = cockpit.format(_("The filesystem is currently mounted on $0 but will be mounted on $1 on the next boot."), other, old_dir); + fix_config_text = cockpit.format(_("Mount automatically on $0 on boot"), other); + fix_mount_text = cockpit.format(_("Mount on $0 now"), old_dir); + } else if (type == "mount-on-boot") { + text = _("The filesystem is currently not mounted but will be mounted on the next boot."); + fix_config_text = _("Do not mount automatically on boot"); + fix_mount_text = _("Mount now"); + } else if (type == "no-mount-on-boot") { + text = _("The filesystem is currently mounted but will not be mounted after the next boot."); + fix_config_text = _("Mount also automatically on boot"); + fix_mount_text = _("Unmount now"); + } else if (type == "mounted-no-config") { + text = cockpit.format(_("The filesystem is currently mounted on $0 but will not be mounted after the next boot."), other); + fix_config_text = cockpit.format(_("Mount automatically on $0 on boot"), other); + fix_mount_text = _("Unmount now"); + } else if (type == "locked-on-boot-mount") { + text = _("The filesystem is configured to be automatically mounted on boot but its encryption container will not be unlocked at that time."); + fix_config_text = _("Do not mount automatically on boot"); + fix_mount_text = _("Unlock automatically on boot"); + } + + return ( + + {text} +
+ {fix_config_text} + { fix_mount_text && {fix_mount_text} } +
+
); +}; + +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")} + + + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/iscsi-session.jsx b/pkg/storaged/pages/iscsi-session.jsx new file mode 100644 index 000000000000..39df39db9b16 --- /dev/null +++ b/pkg/storaged/pages/iscsi-session.jsx @@ -0,0 +1,94 @@ +/* + * 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 { 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 { PageChildrenCard, new_page, page_type, ActionButtons } from "../pages.jsx"; + +import { make_drive_page } from "./drive.jsx"; + +const _ = cockpit.gettext; + +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: () => session.Logout({ 'node.startup': { t: 's', v: "manual" } }), + 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 ( + + + + }}> + {page_type(page)} + + + + + + {_("Address")} + + + {session.data.address} + + + + + {_("Target")} + + + {session.data.target_name} + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/locked-encrypted-data.jsx b/pkg/storaged/pages/locked-encrypted-data.jsx new file mode 100644 index 000000000000..8da23bd10b7a --- /dev/null +++ b/pkg/storaged/pages/locked-encrypted-data.jsx @@ -0,0 +1,77 @@ +/* + * 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 { 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 } 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 ( + + + + }}> + {_("Locked encrypted data")} + + + + + {_("Stored on")} + + + + + + + + + + ); +}; 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..ab4c97565484 --- /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) { + 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), 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..c3d0b2b3695d --- /dev/null +++ b/pkg/storaged/pages/lvm2-thin-pool-logical-volume.jsx @@ -0,0 +1,162 @@ +/* + * 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 { 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 { 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 { 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), 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 ( + + + + }}> + {page_type(page)} + + + + + {_("Stored on")} + + + + + + {_("Name")} + + + {lvol.Name} + + lvol_rename(lvol)}> + {_("edit")} + + + + + + + {_("Size")} + + {fmt_size(lvol.Size)} + + {_("Grow")} + + + + + {_("Data used")} + + {perc(lvol.DataAllocatedRatio)} + + + + {_("Metadata used")} + + {perc(lvol.MetadataAllocatedRatio)} + + + + + + + + + + ); +}; 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..db2c23a1edf2 --- /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) { + 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), 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..6be4f1b51705 --- /dev/null +++ b/pkg/storaged/pages/lvm2-volume-group.jsx @@ -0,0 +1,329 @@ +/* + * 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 } 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) { + 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: function () { + return teardown_active_usage(client, usage) + .then(() => lvol.Delete({ 'tear-down': { t: 'b', v: true } })) + .then(reload_systemd); + } + }, + 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 vgroup_page = new_page({ + location: ["vg", vgroup.Name], + parent, + name: vgroup.Name, + columns: [ + _("LVM2 volume group"), + "/dev/" + vgroup.Name + "/", + fmt_size(vgroup.Size), + ], + component: LVM2VolumeGroupPage, + props: { vgroup }, + actions: [ + { title: _("Rename"), action: () => vgroup_rename(client, vgroup) }, + { 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..1c89de7c16ba --- /dev/null +++ b/pkg/storaged/pages/mdraid-disk.jsx @@ -0,0 +1,163 @@ +/* + * 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, 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 ( + + + + }}> + {page_type(page)} + + + + + {_("Stored on")} + + + + + + {_("RAID device")} + + {mdraid + ? + : "-" + } + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/mdraid.jsx b/pkg/storaged/pages/mdraid.jsx new file mode 100644 index 000000000000..45a05c832d6f --- /dev/null +++ b/pkg/storaged/pages/mdraid.jsx @@ -0,0 +1,345 @@ +/* + * 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, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { SCard } from "../utils/card.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} + + }> + + + + {_("Device")} + + { block ? decode_filename(block.PreferredDevice) : "-" } + + + + {_("UUID")} + + { mdraid.UUID } + + + + {_("Capacity")} + + { fmt_size_long(mdraid.Size) } + + + + {_("RAID level")} + { level } + + + {_("State")} + + { running ? _("Running") : _("Not running") } + + + + + + + + + + { content } + + ); +}; diff --git a/pkg/storaged/pages/nfs.jsx b/pkg/storaged/pages/nfs.jsx new file mode 100644 index 000000000000..a9397cfdcb6a --- /dev/null +++ b/pkg/storaged/pages/nfs.jsx @@ -0,0 +1,356 @@ +/* + * 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 { 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 { + dialog_open, TextInput, ComboBox, CheckBoxes, + StopProcessesMessage, stop_processes_danger_message +} from "../dialog.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]; + + new_page({ + location: ["nfs", remote, local], + parent, + name: remote, + columns: [ + _("NFS mount"), + local, + , + ], + 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 ( + + + + }}> + {page_type(page)} + + + + + + {_("Server")} + + + {entry.fields[0]} + + + + + {_("Mount point")} + + + {entry.fields[1]} + + + + + {_("Size")} + + + + + + + + + + + ); +}; 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..a2e3d77c0c95 --- /dev/null +++ b/pkg/storaged/pages/overview.jsx @@ -0,0 +1,166 @@ +/* + * 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() + }); + if (feature && feature.update) + feature.update(); + }); + }, + () => 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(); + }, + + 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..a4c826e2e66b --- /dev/null +++ b/pkg/storaged/pages/stratis-blockdev.jsx @@ -0,0 +1,123 @@ +/* + * 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.Tier == 0) + desc = cockpit.format(_("$0 data"), + fmt_size(Number(blockdev.TotalPhysicalSize))); + else if (blockdev.Tier == 1) + desc = cockpit.format(_("$0 cache"), + fmt_size(Number(blockdev.TotalPhysicalSize))); + else + desc = cockpit.format(_("$0 of unknown tier"), + fmt_size(Number(blockdev.TotalPhysicalSize))); + + 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; + + console.log("SBD", pool, 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..ade35020c938 --- /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 { 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 { + 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) + ] + }); + } + + new_page({ + location: ["pool", pool.Name, fsys.Name], + parent, + name: fsys.Name, + columns: [ + _("Stratis filesystem"), + mount_point || _("(no mount point)"), + (!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 ( + + + + }}> + {page_type(page)} + + + + + {_("Stored on")} + + + + + + {_("Name")} + + {fsys.Name} + + + + {_("Mount point")} + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/stratis-pool.jsx b/pkg/storaged/pages/stratis-pool.jsx new file mode 100644 index 000000000000..112d18ea5802 --- /dev/null +++ b/pkg/storaged/pages/stratis-pool.jsx @@ -0,0 +1,664 @@ +/* + * 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 { 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 { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; + +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 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.Size), + ], + component: StratisPoolPage, + props: { pool }, + 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 }) => { + 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 (client.features.stratis_grow_blockdevs && + blockdevs.some(bd => bd.NewPhysicalSize[0] && Number(bd.NewPhysicalSize[1]) > Number(bd.TotalPhysicalSize))) { + 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.")} +
+ + {_("Grow the pool to take all space")} + +
+
+
); + } + + if (pool.AvailableActions && pool.AvailableActions !== "fully_operational") { + const goToStratisLogs = () => cockpit.jump("/system/logs/#/?prio=warn&_SYSTEMD_UNIT=stratisd.service"); + alerts.push( + +
+ +
+
+
); + } + + function add_passphrase() { + dialog_open({ + Title: _("Add passphrase"), + Fields: [ + PassInput("passphrase", _("Passphrase"), + { validate: val => !val.length && _("Passphrase cannot be empty") }), + PassInput("passphrase2", _("Confirm"), + { validate: (val, vals) => vals.passphrase.length && vals.passphrase != val && _("Passphrases do not match") }) + ], + Action: { + Title: _("Save"), + action: vals => { + return get_unused_keydesc(client, pool.Name) + .then(keydesc => { + return with_stored_passphrase(client, keydesc, vals.passphrase, + () => pool.BindKeyring(keydesc)) + .then(std_reply); + }); + } + } + }); + } + + function change_passphrase() { + with_keydesc(client, pool, (keydesc, keydesc_set) => { + dialog_open({ + Title: _("Change passphrase"), + Fields: [ + PassInput("old_passphrase", _("Old passphrase"), + { + visible: vals => !keydesc_set, + validate: val => !val.length && _("Passphrase cannot be empty") + }), + PassInput("new_passphrase", _("New passphrase"), + { validate: val => !val.length && _("Passphrase cannot be empty") }), + PassInput("new_passphrase2", _("Confirm"), + { validate: (val, vals) => vals.new_passphrase.length && vals.new_passphrase != val && _("Passphrases do not match") }) + ], + Action: { + Title: _("Save"), + action: vals => { + function rebind() { + return get_unused_keydesc(client, pool.Name) + .then(new_keydesc => { + return with_stored_passphrase(client, new_keydesc, vals.new_passphrase, + () => pool.RebindKeyring(new_keydesc)) + .then(std_reply); + }); + } + + if (vals.old_passphrase) { + return with_stored_passphrase(client, keydesc, vals.old_passphrase, rebind); + } else { + return rebind(); + } + } + } + }); + }); + } + + function remove_passphrase() { + dialog_open({ + Title: _("Remove passphrase?"), + Body:
+

{ fmt_to_fragments(_("Passphrase removal may prevent unlocking $0."), {pool.Name}) }

+
, + Action: { + DangerButton: true, + Title: _("Remove"), + action: function (vals) { + return pool.UnbindKeyring().then(std_reply); + } + } + }); + } + + function add_tang() { + return with_keydesc(client, pool, (keydesc, keydesc_set) => { + dialog_open({ + Title: _("Add Tang keyserver"), + Fields: [ + TextInput("tang_url", _("Keyserver address"), + { + validate: validate_url + }), + PassInput("passphrase", _("Pool passphrase"), + { + visible: () => !keydesc_set, + validate: val => !val.length && _("Passphrase cannot be empty"), + explanation: _("Adding a keyserver requires unlocking the pool. Please provide the existing pool passphrase.") + }) + ], + Action: { + Title: _("Save"), + action: function (vals, progress) { + return get_tang_adv(vals.tang_url) + .then(adv => { + function bind() { + return pool.BindClevis("tang", JSON.stringify({ url: vals.tang_url, adv })) + .then(std_reply); + } + confirm_tang_trust(vals.tang_url, adv, + () => { + if (vals.passphrase) + return with_stored_passphrase(client, keydesc, + vals.passphrase, bind); + else + return bind(); + }); + }); + } + } + }); + }); + } + + function remove_tang() { + dialog_open({ + Title: _("Remove Tang keyserver?"), + Body:
+

{ fmt_to_fragments(_("Remove $0?"), {tang_url}) }

+

{ fmt_to_fragments(_("Keyserver removal may prevent unlocking $0."), {pool.Name}) }

+
, + Action: { + DangerButton: true, + Title: _("Remove"), + action: function (vals) { + return pool.UnbindClevis().then(std_reply); + } + } + }); + } + + const use = pool.TotalPhysicalUsed[0] && [Number(pool.TotalPhysicalUsed[1]), Number(pool.TotalPhysicalSize)]; + + const fsys_actions = ( + create_fs(pool)} + excuse={managed_fsys_sizes && stats.pool_free < fsys_min_size + ? _("Not enough space for new filesystems") + : null}> + {_("Create new filesystem")} + ); + + function add_disks() { + with_keydesc(client, pool, (keydesc, keydesc_set) => { + const ask_passphrase = keydesc && !keydesc_set; + + dialog_open({ + Title: _("Add block devices"), + Fields: [ + SelectOne("tier", _("Tier"), + { + choices: [ + { value: "data", title: _("Data") }, + { + value: "cache", + title: _("Cache"), + disabled: pool.Encrypted && !client.features.stratis_encrypted_caches + } + ] + }), + PassInput("passphrase", _("Passphrase"), + { + visible: () => ask_passphrase, + validate: val => !val.length && _("Passphrase cannot be empty"), + }), + SelectSpaces("disks", _("Block devices"), + { + 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) + }) + ], + Action: { + Title: _("Add"), + action: function(vals) { + return prepare_available_spaces(client, vals.disks) + .then(paths => { + const devs = paths.map(p => decode_filename(client.blocks[p].PreferredDevice)); + + function add() { + if (vals.tier == "data") { + return pool.AddDataDevs(devs).then(std_reply); + } else if (vals.tier == "cache") { + const has_cache = blockdevs.some(bd => bd.Tier == 1); + const method = has_cache ? "AddCacheDevs" : "InitCache"; + return pool[method](devs).then(std_reply); + } + } + + if (ask_passphrase) { + return with_stored_passphrase(client, keydesc, vals.passphrase, add); + } else + return add(); + }); + } + } + }); + }); + } + + const blockdev_actions = ( + + {_("Add block devices")} + ); + + return ( + + {alerts} + + + }}> + {page_type(page)} + + + + + + {_("UUID")} + + {pool.Uuid} + + { !managed_fsys_sizes && use && + + + {_("Usage")} + + + + + + } + { pool.Encrypted && client.features.stratis_crypto_binding && + + + {_("Passphrase")} + + + + { !key_desc + ? {_("Add passphrase")} + : <> + {_("Change")} + + + {_("Remove")} + + + + } + + + + } + { can_tang && + + + {_("Keyserver")} + + + + { tang_url == null + ? {_("Add keyserver")} + : <> + { tang_url } + + + {_("Remove")} + + + + } + + + + } + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/stratis-stopped-pool.jsx b/pkg/storaged/pages/stratis-stopped-pool.jsx new file mode 100644 index 000000000000..65a7c9355745 --- /dev/null +++ b/pkg/storaged/pages/stratis-stopped-pool.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 { 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 { List, ListItem } from "@patternfly/react-core/dist/esm/components/List/index.js"; + +import { PageChildrenCard, PageCrossrefCard, ActionButtons, new_page, page_type, get_crossrefs } from "../pages.jsx"; +import { dialog_open, PassInput } from "../dialog.jsx"; +import { std_reply, with_stored_passphrase } from "../stratis-utils.js"; + +const _ = cockpit.gettext; + +function start_pool(uuid, show_devs) { + const devs = client.stratis_manager.StoppedPools[uuid].devs.v.map(d => d.devnode).sort(); + const key_desc = client.stratis_stopped_pool_key_description[uuid]; + const clevis_info = client.stratis_stopped_pool_clevis_info[uuid]; + + function start(unlock_method) { + return client.stratis_start_pool(uuid, unlock_method).then(std_reply); + } + + function unlock_with_keydesc(key_desc) { + dialog_open({ + Title: _("Unlock encrypted Stratis pool"), + Body: (show_devs && + <> +

{_("Provide the passphrase for the pool on these block devices:")}

+ {devs.map(d => {d})} +
+ ), + Fields: [ + PassInput("passphrase", _("Passphrase"), { }) + ], + Action: { + Title: _("Unlock"), + action: function(vals) { + return with_stored_passphrase(client, key_desc, vals.passphrase, + () => start("keyring")); + } + } + }); + } + + function unlock_with_keyring() { + return (client.stratis_list_keys() + .catch(() => [{ }]) + .then(keys => { + if (keys.indexOf(key_desc) >= 0) + return start("keyring"); + else + unlock_with_keydesc(key_desc); + })); + } + + if (!key_desc && !clevis_info) { + // Not an encrypted pool, just start it + return start(); + } else if (key_desc && clevis_info) { + return start("clevis").catch(unlock_with_keyring); + } else if (!key_desc && clevis_info) { + return start("clevis"); + } else if (key_desc && !clevis_info) { + return unlock_with_keyring(); + } +} + +export function make_stratis_stopped_pool_page(parent, uuid) { + new_page({ + location: ["pool", uuid], + parent, + name: uuid, + columns: [ + _("Stopped Stratis pool"), + null, + null, + ], + component: StoppedStratisPoolPage, + props: { uuid }, + actions: [ + { title: _("Start"), action: () => start_pool(uuid, true), }, // XXX - show_devs? + ], + }); +} + +const StoppedStratisPoolPage = ({ page, uuid }) => { + const key_desc = client.stratis_stopped_pool_key_description[uuid]; + const clevis_info = client.stratis_stopped_pool_clevis_info[uuid]; + + const encrypted = key_desc || clevis_info; + const can_tang = encrypted && (!clevis_info || clevis_info[0] == "tang"); + const tang_url = (can_tang && clevis_info) ? JSON.parse(clevis_info[1]).url : null; + + return ( + + + + }}> + {page_type(page)} + + + + + + {_("UUID")} + + {uuid} + + { encrypted && client.features.stratis_crypto_binding && + + + {_("Passphrase")} + + + { key_desc ? cockpit.format(_("using key description $0"), key_desc) : _("none") } + + + } + { can_tang && client.features.stratis_crypto_binding && + + + {_("Keyserver")} + + + { tang_url || _("none") } + + + } + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/unrecognized-data.jsx b/pkg/storaged/pages/unrecognized-data.jsx new file mode 100644 index 000000000000..044de9a411e7 --- /dev/null +++ b/pkg/storaged/pages/unrecognized-data.jsx @@ -0,0 +1,78 @@ +/* + * 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 { + ParentPageLink, PageContainerStackItems, + new_page, block_location, ActionButtons, page_type, +} from "../pages.jsx"; +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.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_unrecognized_data_page(parent, backing_block, content_block, container) { + new_page({ + location: [block_location(backing_block)], + parent, + container, + name: block_name(backing_block), + columns: [ + _("Unrecognized data"), + null, + fmt_size(backing_block.Size) + ], + component: UnrecognizedDataPage, + props: { backing_block, content_block }, + actions: [ + std_lock_action(backing_block, content_block), + { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + ] + }); +} + +export const UnrecognizedDataPage = ({ page, backing_block, content_block }) => { + return ( + + + }> + + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/resize.jsx b/pkg/storaged/resize.jsx index 49f17f199bad..c7cd2f2760e9 100644 --- a/pkg/storaged/resize.jsx +++ b/pkg/storaged/resize.jsx @@ -19,6 +19,8 @@ import React from "react"; import cockpit from "cockpit"; +import client from "./client.js"; + import { block_name, get_active_usage, teardown_active_usage, is_mounted_synch, get_partitions @@ -36,6 +38,74 @@ import { pvs_to_spaces } from "./content-views.jsx"; const _ = cockpit.gettext; +export function check_unused_space(path) { + const block = client.blocks[path]; + const lvm2 = client.blocks_lvm2[path]; + const lvol = lvm2 && client.lvols[lvm2.LogicalVolume]; + const part = client.blocks_part[path]; + + let size, min_change; + + if (lvol) { + size = lvol.Size; + min_change = client.vgroups[lvol.VolumeGroup].ExtentSize; + } else if (part) { + size = part.Size; + min_change = 1024 * 1024; + } else { + return null; + } + + if (size != block.Size) { + // Let's ignore inconsistent lvol,part/block combinations. + // These happen during a resize and the inconsistency will + // eventually go away. + return null; + } + + let content_path = null; + let crypto_overhead = 0; + + const crypto = client.blocks_crypto[block.path]; + const cleartext = client.blocks_cleartext[block.path]; + if (crypto) { + if (crypto.MetadataSize !== undefined && cleartext) { + content_path = cleartext.path; + crypto_overhead = crypto.MetadataSize; + } + } else { + content_path = path; + } + + const fsys = client.blocks_fsys[content_path]; + const content_block = client.blocks[content_path]; + const vdo = content_block ? client.legacy_vdo_overlay.find_by_backing_block(content_block) : null; + const stratis_bdev = client.blocks_stratis_blockdev[content_path]; + + if (fsys && fsys.Size && (size - fsys.Size - crypto_overhead) > min_change && fsys.Resize) { + return { + volume_size: size - crypto_overhead, + content_size: fsys.Size + }; + } + + if (vdo && (size - vdo.physical_size - crypto_overhead) > min_change) { + return { + volume_size: size - crypto_overhead, + content_size: vdo.physical_size + }; + } + + if (stratis_bdev && (size - Number(stratis_bdev.TotalPhysicalSize) - crypto_overhead) > min_change) { + return { + volume_size: size - crypto_overhead, + content_size: Number(stratis_bdev.TotalPhysicalSize) + }; + } + + return null; +} + function lvol_or_part_and_fsys_resize(client, lvol_or_part, size, offline, passphrase, pvs) { let fsys; let crypto_overhead; diff --git a/pkg/storaged/storage-controls.jsx b/pkg/storaged/storage-controls.jsx index ce3770f70638..757f384b7acb 100644 --- a/pkg/storaged/storage-controls.jsx +++ b/pkg/storaged/storage-controls.jsx @@ -68,7 +68,7 @@ class StorageControl extends React.Component { } } -function checked(callback, setSpinning) { +function checked(callback, setSpinning, excuse) { return function (event) { if (!event) return; @@ -81,6 +81,16 @@ function checked(callback, setSpinning) { if (event.type === 'KeyDown' && event.key !== "Enter") return; + event.stopPropagation(); + + if (excuse) { + dialog_open({ + Title: _("Sorry"), + Body: excuse + }); + return; + } + const promise = client.run(callback); if (promise) { if (setSpinning) @@ -97,7 +107,6 @@ function checked(callback, setSpinning) { }); }); } - event.stopPropagation(); }; } @@ -226,10 +235,10 @@ export const StorageUsageBar = ({ stats, critical, block, offset, total, small } ); }; -export const StorageMenuItem = ({ onClick, onlyNarrow, danger, children }) => ( +export const StorageMenuItem = ({ onClick, onlyNarrow, danger, excuse, children }) => ( + onKeyDown={checked(onClick, null, excuse)} + onClick={checked(onClick, null, excuse)}> {children} ); @@ -240,12 +249,20 @@ export const StorageBarMenu = ({ label, isKebab, onlyNarrow, menuItems }) => { if (!client.superuser.allowed) return null; + function onToggle(event, isOpen) { + // Tell Overview that we handled this event. We can't use + // stopPropagation() since the Toggles depend on seeing other + // Togglers events at the top level to close themselves. + event.preventDefault(); + setIsOpen(isOpen); + } + let toggle; if (isKebab) - toggle = setIsOpen(isOpen)} />; + toggle = ; else toggle = setIsOpen(isOpen)} aria-label={label}> + onToggle={onToggle} aria-label={label}> ; diff --git a/pkg/storaged/storage-page.jsx b/pkg/storaged/storage-page.jsx new file mode 100644 index 000000000000..447e62ce83b7 --- /dev/null +++ b/pkg/storaged/storage-page.jsx @@ -0,0 +1,57 @@ +/* + * 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 { get_page_from_location } from "./pages.jsx"; + +import { Page, PageBreadcrumb, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js"; +import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js"; + +export const StoragePage = ({ location, plot_state }) => { + const page = get_page_from_location(location); + + // XXX - global alerts here, Multipath, Anaconda + + const parent_crumbs = []; + let pp = page.parent; + while (pp) { + parent_crumbs.unshift( + + {pp.name} + + ); + pp = pp.parent; + } + + return ( + + + + { parent_crumbs } + {page.name} + + + + + + + ); +}; diff --git a/pkg/storaged/storage.scss b/pkg/storaged/storage.scss index 39de034e56e8..8bad1e15806b 100644 --- a/pkg/storaged/storage.scss +++ b/pkg/storaged/storage.scss @@ -197,16 +197,10 @@ tr[class*="content-level-"] { --multiplier: 0; --offset: calc(var(--pf-v5-global--spacer--lg) * var(--multiplier)); - // Move the button over - > .pf-v5-c-table__toggle > button { + > td:first-child { position: relative; inset-inline-start: var(--offset); } - - // Add space for the button and offset - > .pf-v5-c-table__toggle + td { - padding-inline-start: calc(var(--offset) + var(--pf-v5-c-table--cell--PaddingLeft)); - } } @for $i from 1 through 10 { diff --git a/pkg/storaged/storaged.jsx b/pkg/storaged/storaged.jsx index 4a04b8e381e9..09c461c85412 100644 --- a/pkg/storaged/storaged.jsx +++ b/pkg/storaged/storaged.jsx @@ -16,6 +16,7 @@ * You should have received a copy of the GNU Lesser General Public License * along with Cockpit; If not, see . */ + import '../lib/patternfly/patternfly-5-cockpit.scss'; import 'polyfills'; // once per application import 'cockpit-dark-theme'; // once per page @@ -29,38 +30,35 @@ import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; import { PlotState } from "plot.js"; import client from "./client"; -import { MultipathAlert } from "./multipath.jsx"; -import { Overview } from "./overview.jsx"; -import { Details } from "./details.jsx"; import { update_plot_state } from "./plot.jsx"; +import { StoragePage } from "./storage-page.jsx"; import "./storage.scss"; const _ = cockpit.gettext; -class StoragePage extends React.Component { +class Application extends React.Component { constructor() { super(); this.state = { inited: false, slow_init: false, path: cockpit.location.path }; this.plot_state = new PlotState(); - this.on_client_changed = () => { if (!this.props.client.busy) this.setState({}); }; + this.on_client_changed = () => { if (!client.busy) this.setState({}); }; this.on_navigate = () => { this.setState({ path: cockpit.location.path }) }; } componentDidMount() { - this.props.client.addEventListener("changed", this.on_client_changed); + client.addEventListener("changed", this.on_client_changed); cockpit.addEventListener("locationchanged", this.on_navigate); client.init(() => { this.setState({ inited: true }) }); window.setTimeout(() => { if (!this.state.inited) this.setState({ slow_init: true }); }, 1000); } componentWillUnmount() { - this.props.client.removeEventListener("changed", this.on_client_changed); + client.removeEventListener("changed", this.on_client_changed); cockpit.removeEventListener("locationchanged", this.on_navigate); } render() { - const { client } = this.props; const { inited, slow_init, path } = this.state; if (!inited) { @@ -77,26 +75,13 @@ class StoragePage extends React.Component { // alive no matter what page is shown. update_plot_state(this.plot_state, client); - let detail; - if (path.length === 0) - detail = null; - else if (path.length == 1) - detail =
; - else - detail =
; - - return ( - <> - - {detail || } - - ); + return ; } } function init() { const root = createRoot(document.getElementById('storage')); - root.render(); + root.render(); document.body.removeAttribute("hidden"); window.addEventListener('beforeunload', event => { diff --git a/pkg/storaged/utils.js b/pkg/storaged/utils.js index 20971698b330..b971ef313334 100644 --- a/pkg/storaged/utils.js +++ b/pkg/storaged/utils.js @@ -826,7 +826,7 @@ export function get_active_usage(client, path, top_action, child_action) { usage: 'mdraid-member', block, mdraid, - location: mdraid_name(mdraid.Name), + location: mdraid_name(mdraid), actions: get_actions(_("remove from RAID")), blocking: !(active_state && active_state[1] < 0) }); diff --git a/pkg/storaged/utils/card.jsx b/pkg/storaged/utils/card.jsx new file mode 100644 index 000000000000..a18218f2e6e5 --- /dev/null +++ b/pkg/storaged/utils/card.jsx @@ -0,0 +1,32 @@ +/* + * 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 React from "react"; + +import { Card, CardHeader, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js"; + +export const SCard = ({ title, actions, children }) => { + return ( + + + {title} + + {children} + ); +}; diff --git a/pkg/storaged/utils/desc.jsx b/pkg/storaged/utils/desc.jsx new file mode 100644 index 000000000000..82cbc8ab61d9 --- /dev/null +++ b/pkg/storaged/utils/desc.jsx @@ -0,0 +1,33 @@ +/* + * 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 React from "react"; + +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +export const SDesc = ({ title, value, children }) => { + if (!value && !children) + return null; + + return ( + + {title} + {value}{children} + ); +}; diff --git a/pkg/storaged/vgroup-details.jsx b/pkg/storaged/vgroup-details.jsx index 83b5ab872c6c..9b460f3b63fa 100644 --- a/pkg/storaged/vgroup-details.jsx +++ b/pkg/storaged/vgroup-details.jsx @@ -154,7 +154,7 @@ export function vgroup_rename(client, vgroup) { }); } -export function vgroup_delete(client, vgroup) { +export function vgroup_delete(client, vgroup, parent_page) { const location = cockpit.location; const usage = utils.get_active_usage(client, vgroup.path, _("delete")); @@ -180,7 +180,7 @@ export function vgroup_delete(client, vgroup) { { 'tear-down': { t: 'b', v: true } }) .then(utils.reload_systemd) .then(function () { - location.go('/'); + location.go(parent_page.location); }); }); } diff --git a/test/common/storagelib.py b/test/common/storagelib.py index 853a5775054e..01dead7d1b74 100644 --- a/test/common/storagelib.py +++ b/test/common/storagelib.py @@ -636,6 +636,37 @@ def encrypt_root(self, passphrase): m.wait_reboot(300) self.assertEqual(m.execute("findmnt -n -o SOURCE /").strip(), "/dev/mapper/root-root") + # THE NEW STUFF + + def card(self, title): + return f"[data-test-card-title='{title}']" + + def card_header(self, title): + return self.card(title) + " .pf-v5-c-card__header" + + def card_row(self, title, index=None, name=None, location=None): + if index is not None: + return self.card(title) + f" tr:nth-child({index})" + elif name is not None: + return self.card(title) + f" [data-test-row-name='{name}']" + else: + return self.card(title) + f" [data-test-row-location='{location}']" + + def card_row_col(self, title, row_index, col_index): + return self.card_row(title, row_index) + f" td:nth-child({col_index})" + + def card_desc(self, card_title, desc_title): + return self.card(card_title) + f" [data-test-desc-title='{desc_title}'] dd" + + def dropdown_action(self, parent, title): + return [ + parent + " .pf-v5-c-dropdown button.pf-v5-c-dropdown__toggle", + parent + f" .pf-v5-c-dropdown a:contains('{title}')" + ] + + def card_button(self, card_title, button_title): + return self.card_header(card_title) + f" button:contains('{button_title}')" + class StorageCase(MachineCase, StorageHelpers): diff --git a/test/common/testlib.py b/test/common/testlib.py index a3efba4c75a8..92874a86e6ec 100644 --- a/test/common/testlib.py +++ b/test/common/testlib.py @@ -384,6 +384,14 @@ def click(self, selector: str): """ self.mouse(selector + ":not([disabled]):not([aria-disabled=true])", "click", 0, 0, 0) + def clicks(self, selectors): + """Click on a couple of ui elements + + :param selectors: the selectors to click on + """ + for sel in selectors: + self.click(sel) + def val(self, selector: str): """Get the value attribute of a selector. diff --git a/test/verify/check-storage-basic b/test/verify/check-storage-basic index d46f9d942099..db863ded0898 100755 --- a/test/verify/check-storage-basic +++ b/test/verify/check-storage-basic @@ -20,7 +20,6 @@ import storagelib import testlib - @testlib.nondestructive class TestStorageBasic(storagelib.StorageCase): @@ -30,46 +29,46 @@ class TestStorageBasic(storagelib.StorageCase): self.login_and_go("/storage", superuser=False) - b.wait_visible("#devices") - b.wait_not_present("#devices .pf-v5-c-dropdown button") + create_dropdown = self.card("Storage") + " .pf-v5-c-card__header .pf-v5-c-dropdown" + + b.wait_visible(self.card("Storage")) + b.wait_not_present(create_dropdown) b.relogin('/storage', superuser=True) - b.wait_visible("#devices .pf-v5-c-dropdown button:not([disabled])") + b.wait_visible(create_dropdown) # Add a disk, partition it, format it, and finally remove it. disk = self.add_ram_disk() - b.click(f'.sidepanel-row:contains("{disk}")') - b.wait_visible('#storage-detail') - self.content_row_wait_in_col(1, 2, "Unrecognized data") + b.click(self.card_row("Storage", location=disk)) + b.wait_visible(self.card("Drive")) - def info_field_value(name): - return b.text(f'#detail-header dt:contains("{name}") + dd') + b.wait_text(self.card_desc("Drive", "Model"), "scsi_debug") + b.wait_in_text(self.card_desc("Drive", "Capacity"), "50 MiB") - self.assertEqual(self.inode(info_field_value("Device file")), self.inode(disk)) + self.assertEqual(self.inode(b.text(self.card_desc("Drive", "Device file"))), self.inode(disk)) m.execute(f'parted -s {disk} mktable gpt') m.execute(f'parted -s {disk} mkpart primary ext2 1M 8M') - self.content_row_wait_in_col(1, 2, "Unrecognized data") - self.content_tab_wait_in_info(1, 1, "Name", "primary") + b.wait_text(self.card_row_col("Partitions", 1, 2), "Unrecognized data") # create filesystem on the first partition # HACK - the block device might disappear briefly when udevd does its BLKRRPART. testlib.wait(lambda: m.execute(f'mke2fs {disk}1'), delay=1, tries=5) - self.content_row_wait_in_col(1, 2, "ext2 filesystem") + b.wait_text(self.card_row_col("Partitions", 1, 2), "ext2 filesystem") - self.content_tab_expand(1, 1) - b.assert_pixels("#detail-content", "partition", + b.click(self.card_row("Partitions", 1)) + b.wait_text(self.card_desc("Partition", "Name"), "primary") + b.assert_pixels(self.card("Partition"), "partition", mock={"dt:contains(UUID) + dd": "a12978a1-5d6e-f24f-93de-11789977acde"}) - self.content_tab_expand(1, 2) - b.assert_pixels("#detail-content", "filesystem") + b.assert_pixels(self.card("ext2 filesystem"), "filesystem") b.go("#/") - b.wait_visible('#storage') - b.wait_in_text("#drives", disk) + b.wait_visible(self.card("Storage")) + b.wait_visible(self.card_row("Storage", location=disk)) self.force_remove_disk(disk) - b.wait_not_in_text("#drives", disk) + b.wait_not_present(self.card_row("Storage", location=disk)) if __name__ == '__main__': diff --git a/test/verify/check-storage-hidden b/test/verify/check-storage-hidden index f369a36f91fb..546d3598607c 100755 --- a/test/verify/check-storage-hidden +++ b/test/verify/check-storage-hidden @@ -17,10 +17,10 @@ # You should have received a copy of the GNU Lesser General Public License # along with Cockpit; If not, see . +import os import storagelib import testlib - @testlib.nondestructive class TestStorageHiddenLuks(storagelib.StorageCase): def test(self): @@ -32,28 +32,26 @@ class TestStorageHiddenLuks(storagelib.StorageCase): self.login_and_go("/storage") disk = self.add_ram_disk() - b.wait_in_text("#drives", disk) + b.wait_visible(self.card_row("Storage", location=disk)) # Create a volume group with a logical volume with a encrypted # filesystem. - self.devices_dropdown('Create LVM2 volume group') + b.clicks(self.dropdown_action(self.card_header("Storage"), "Create LVM2 volume group")) self.dialog_wait_open() self.dialog_set_val('name', "TEST") self.dialog_set_val('disks', {disk: True}) self.dialog_apply() self.dialog_wait_close() - b.wait_in_text('#devices', "TEST") - b.click('#devices .sidepanel-row:contains("TEST")') - b.wait_visible('#storage-detail') - b.click("button:contains(Create new logical volume)") + b.click(self.card_row("Storage", name="TEST")) + b.click(self.card_button("Logical volumes", "Create new logical volume")) self.dialog({'purpose': "block", 'name': "lvol", 'size': 48}) - self.content_row_wait_in_col(1, 1, "lvol") + b.wait_text(self.card_row_col("Logical volumes", 1, 1), "lvol") - self.content_row_action(1, "Format") + b.clicks(self.dropdown_action(self.card_row("Logical volumes", 1), "Format")) self.dialog({"type": "ext4", "crypto": self.default_crypto_type, "name": "FS", @@ -66,17 +64,17 @@ class TestStorageHiddenLuks(storagelib.StorageCase): self.assert_in_child_configuration("/dev/TEST/lvol", "fstab", "dir", mount_point_1) self.assert_in_lvol_child_configuration("lvol", "crypttab", "options", "my-crypto-tag") self.assert_in_lvol_child_configuration("lvol", "fstab", "dir", mount_point_1) - self.content_row_wait_in_col(1, 2, "Filesystem (encrypted)") + b.wait_text(self.card_row_col("Logical volumes", 1, 2), "Filesystem (encrypted)") # Now the filesystem is hidden because the LUKS device is # locked. Doubly hide it by deactivating /dev/TEST/lvol - self.content_dropdown_action(1, "Deactivate") - self.content_row_wait_in_col(1, 2, "Inactive volume") + b.clicks(self.dropdown_action(self.card_row("Logical volumes", 1), "Deactivate")) + b.wait_text(self.card_row_col("Logical volumes", 1, 2), "Inactive logical volume") # Deleting the volume group should still remove the fstab entry - b.click('.pf-v5-c-card__header:contains("LVM2 volume group") button:contains("Delete")') + b.click(self.card_button("LVM2 volume group", "Delete")) self.confirm() - b.wait_visible("#storage") + b.wait_visible(self.card("Storage")) self.assertEqual(m.execute(f"grep {mount_point_1} /etc/fstab || true"), "") self.assertEqual(m.execute(f"grep {'my-crypto-tag'} /etc/crypttab || true"), "") @@ -94,34 +92,32 @@ class TestStorageHidden(storagelib.StorageCase): disk1 = self.add_loopback_disk() disk2 = self.add_loopback_disk() - b.wait_in_text("#others", disk1) - b.wait_in_text("#others", disk2) - - # Now do the same with a MDRAID + b.wait_visible(self.card_row("Storage", location=disk1)) + b.wait_visible(self.card_row("Storage", location=disk2)) - self.dialog_with_retry(trigger=lambda: self.devices_dropdown('Create RAID device'), + self.dialog_with_retry(trigger=lambda: b.clicks(self.dropdown_action(self.card_header("Storage"), + "Create RAID device")), expect=lambda: (self.dialog_is_present('disks', disk1) and self.dialog_is_present('disks', disk2)), values={"name": "ARR", "disks": {disk1: True, disk2: True}}) - b.wait_in_text('#devices', "ARR") - b.click('#devices .sidepanel-row:contains("ARR")') - b.wait_visible('#storage-detail') - self.content_row_action(1, "Format") + b.click(self.card_row("Storage", name="ARR")) + b.wait_visible(self.card("RAID device")) + b.clicks(self.dropdown_action(self.card_row("Content", 1), "Format")) self.dialog({"type": "ext4", "name": "FS2", "mount_point": mount_point_2}) self.assert_in_configuration("/dev/md127", "fstab", "dir", mount_point_2) - self.content_row_wait_in_col(1, 2, "ext4 filesystem") + b.wait_text(self.card_row_col("Content", 1, 2), "ext4 filesystem") # we need to wait for mdadm --monitor to stop using the device before delete m.execute("while fuser -s /dev/md127; do sleep 0.2; done", timeout=20) - self.browser.click('.pf-v5-c-card__header:contains("RAID device") button:contains("Delete")') + b.click(self.card_button("RAID device", "Delete")) self.confirm() - b.wait_visible("#storage") + b.wait_visible(self.card("Storage")) self.assertEqual(m.execute(f"grep {mount_point_2} /etc/fstab || true"), "") @testlib.onlyImage("Only test snaps on Ubuntu", "ubuntu*") @@ -145,10 +141,11 @@ class TestStorageHidden(storagelib.StorageCase): # Now we wait until the regular loopback device is shown. The # snaps should not be shown. - b.wait_in_text("#others", dev) + b.wait_visible(self.card_row("Storage", location=dev)) + b.wait_visible(self.card_row("Storage", name=os.path.basename(dev))) for sl in snap_loops: - b.wait_not_in_text("#others", sl) - b.wait_not_in_text("#mounts", sl) + b.wait_not_visible(self.card_row("Storage", location=sl)) + b.wait_not_visible(self.card_row("Storage", name=os.path.basename(sl))) if __name__ == '__main__': diff --git a/test/verify/check-storage-ignored b/test/verify/check-storage-ignored index a506e16b79db..2d977e1ef96d 100755 --- a/test/verify/check-storage-ignored +++ b/test/verify/check-storage-ignored @@ -20,7 +20,6 @@ import storagelib import testlib - @testlib.nondestructive class TestStorageIgnored(storagelib.StorageCase): @@ -29,20 +28,28 @@ class TestStorageIgnored(storagelib.StorageCase): b = self.browser self.login_and_go("/storage") - disk = self.add_ram_disk() - b.wait_in_text("#drives", disk) + disk = self.add_loopback_disk() + b.wait_visible(self.card_row("Storage", location=disk)) + b.click(self.card_row("Storage", location=disk)) + b.wait_visible(self.card("Block device")) + b.click(self.card_row("Content", 1)) + b.wait_visible(self.card("Unrecognized data")) m.execute(f"yes | mke2fs -q -L TESTLABEL {disk}") with b.wait_timeout(30): - b.wait_in_text("#mounts", disk + " (TESTLABEL)") + b.wait_in_text(self.card_desc("ext2 filesystem", "Name"), "TESTLABEL") - # Hide it via a udev rule + # Hide it via a udev rule. m.write("/run/udev/rules.d/99-ignore.rules", 'SUBSYSTEM=="block", ENV{ID_FS_LABEL}=="TESTLABEL", ENV{UDISKS_IGNORE}="1"\n') self.addCleanup(m.execute, "rm /run/udev/rules.d/99-ignore.rules; udevadm control --reload; udevadm trigger") m.execute("udevadm control --reload; udevadm trigger") - b.wait_not_in_text("#mounts", "TESTLABEL") - b.wait_not_in_text("#mounts", disk) + + b.wait_in_text(".pf-v5-c-breadcrumb", "Not found") + b.go("#/") + b.wait_visible(self.card("Storage")) + b.wait_not_present(self.card_row("Storage", location=disk)) + b.wait_not_present(self.card_row("Storage", name=disk)) if __name__ == '__main__': diff --git a/test/verify/check-storage-unused b/test/verify/check-storage-unused index 8cb55533bf4b..a16e539220fc 100755 --- a/test/verify/check-storage-unused +++ b/test/verify/check-storage-unused @@ -42,8 +42,8 @@ class TestStorageUnused(storagelib.StorageCase): disk1 = self.add_ram_disk() disk2 = self.add_loopback_disk() - b.wait_in_text("#drives", disk1) - b.wait_in_text("#others", disk2) + b.wait_visible(self.card_row("Storage", location=disk1)) + b.wait_visible(self.card_row("Storage", location=disk2)) script = """mktable msdos \ mkpart extended 1 50 \ mkpart logical ext2 2 24 \ @@ -73,7 +73,8 @@ mkpart logical ext2 24 48""" # Require these two to be present return f"{disk1}6" in blocks and disk2 in blocks - self.dialog_with_retry(trigger=lambda: self.devices_dropdown('Create RAID device'), + self.dialog_with_retry(trigger=lambda: b.clicks(self.dropdown_action(self.card_header("Storage"), + "Create RAID device")), expect=check_free_block_devices, values=None) diff --git a/test/verify/check-storage-used b/test/verify/check-storage-used index c15a88fd520f..30377b5836f7 100755 --- a/test/verify/check-storage-used +++ b/test/verify/check-storage-used @@ -31,7 +31,7 @@ class TestStorageUsed(storagelib.StorageCase): self.login_and_go("/storage") disk = self.add_ram_disk() - b.wait_in_text("#drives", disk) + b.wait_visible(self.card_row("Storage", location=disk)) m.execute(f"parted -s {disk} mktable msdos") m.execute(f"parted -s {disk} mkpart primary ext2 1M 25") m.execute("udevadm settle") @@ -59,10 +59,10 @@ ExecStart=/usr/bin/sleep infinity # Now all of /dev/mapper/dm-test, /dev/sda1, and /dev/sda # should be 'in use' but Cockpit can clean them all up anyway. - b.click(f'.sidepanel-row:contains("{disk}")') - b.wait_visible("#storage-detail") + b.click(self.card_row("Storage", location=disk)) + b.wait_visible(self.card("Drive")) - self.content_dropdown_action(1, "Format") + b.clicks(self.dropdown_action(self.card_row("Partitions", 1), "Format")) self.dialog_wait_open() b.click("#dialog button:contains(Currently in use)") b.wait_in_text(".pf-v5-c-popover", str(sleep_pid)) @@ -75,7 +75,7 @@ ExecStart=/usr/bin/sleep infinity self.dialog_cancel() self.dialog_wait_close() - self.content_dropdown_action(1, "Delete") + b.clicks(self.dropdown_action(self.card_row("Partitions", 1), "Delete")) self.dialog_wait_open() b.wait_visible("#dialog button:contains(Currently in use)") b.assert_pixels('#dialog', "delete") @@ -84,7 +84,7 @@ ExecStart=/usr/bin/sleep infinity # No go ahead and let the automatic teardown take care of the mount - b.click('button:contains(Create partition table)') + b.click(self.card_button("Partitions", "Create partition table")) self.dialog_wait_open() b.wait_visible("#dialog tr:first-child button:contains(Currently in use)") b.assert_pixels('#dialog', "format-disk") @@ -93,7 +93,7 @@ ExecStart=/usr/bin/sleep infinity m.execute("! systemctl --quiet is-active keep-mnt-busy") - self.content_row_wait_in_col(1, 0, "Free space") + b.wait_text(self.card_row_col("Partitions", 1, 1), "Free space") def testUsedAsPV(self): m = self.machine @@ -103,19 +103,19 @@ ExecStart=/usr/bin/sleep infinity dev_1 = self.add_ram_disk() dev_2 = self.add_loopback_disk() - b.wait_in_text("#drives", dev_1) - b.wait_in_text("#others", dev_2) + b.wait_visible(self.card_row("Storage", location=dev_1)) + b.wait_visible(self.card_row("Storage", location=dev_2)) # Create a volume group out of two disks m.execute(f"vgcreate TEST1 {dev_1} {dev_2}") self.addCleanup(m.execute, "vgremove --force TEST1 2>/dev/null || true") - b.wait_in_text("#devices", "TEST1") + b.wait_visible(self.card_row("Storage", name="TEST1")) # Formatting dev_1 should cleanly remove it from the volume # group. - b.click(f'.sidepanel-row:contains("{dev_1}")') - b.click('button:contains("Create partition table")') + b.click(self.card_row("Storage", location=dev_1)) + b.click(self.card_button("Content", "Create partition table")) b.wait_in_text('#dialog', "remove from LVM2, initialize") self.dialog_apply() self.dialog_wait_close() @@ -125,8 +125,8 @@ ExecStart=/usr/bin/sleep infinity # group. b.go("#/") - b.click(f'.sidepanel-row:contains("{dev_2}")') - b.click('button:contains("Create partition table")') + b.click(self.card_row("Storage", location=dev_2)) + b.click(self.card_button("Content", "Create partition table")) b.wait_in_text('#dialog', "remove from LVM2, initialize") self.dialog_apply() self.dialog_wait_close() diff --git a/test/verify/storageutils.py b/test/verify/storageutils.py new file mode 100644 index 000000000000..7c13c050c9f9 --- /dev/null +++ b/test/verify/storageutils.py @@ -0,0 +1,16 @@ +def card(title): + return f"[data-test-card-title='{title}']" + +def card_row(title, index=None, name=None, location=None): + if index is not None: + return card(title) + f" tr:nth-child({index})" + elif name is not None: + return card(title) + f" [data-test-row-name='{name}']" + else: + return card(title) + f" [data-test-row-location='{location}']" + +def card_row_col(title, row_index, col_index): + return card_row(title, row_index) + f" td:nth-child({col_index})" + +def card_desc(card_title, desc_title): + return card(card_title) + f" [data-test-desc-title='{desc_title}'] dd"