From 9a0188694f64b962b88b89bad6efa082d8b08845 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Tue, 17 Oct 2023 10:37:50 +0300 Subject: [PATCH] WIP - redesign --- pkg/storaged/actions.jsx | 74 ++ pkg/storaged/client.js | 42 +- pkg/storaged/containers/encryption.jsx | 251 +++++++ .../containers/lvm2-logical-volume.jsx | 206 ++++++ pkg/storaged/containers/partition.jsx | 144 ++++ pkg/storaged/content-views.jsx | 6 +- pkg/storaged/create-pages.jsx | 168 +++++ pkg/storaged/crypto-keyslots.jsx | 6 +- pkg/storaged/dialog.jsx | 4 +- pkg/storaged/iscsi-panel.jsx | 4 +- pkg/storaged/lvol-tabs.jsx | 4 +- pkg/storaged/multipath.jsx | 17 +- pkg/storaged/overview.jsx | 71 -- pkg/storaged/pages.jsx | 366 ++++++++++ pkg/storaged/pages/drive.jsx | 141 ++++ pkg/storaged/pages/filesystem.jsx | 439 ++++++++++++ pkg/storaged/pages/iscsi-session.jsx | 85 +++ pkg/storaged/pages/locked-encrypted-data.jsx | 77 +++ .../pages/lvm2-inactive-logical-volume.jsx | 72 ++ pkg/storaged/pages/lvm2-physical-volume.jsx | 155 +++++ .../pages/lvm2-thin-pool-logical-volume.jsx | 162 +++++ .../pages/lvm2-unsupported-logical-volume.jsx | 74 ++ pkg/storaged/pages/lvm2-volume-group.jsx | 329 +++++++++ pkg/storaged/pages/mdraid-disk.jsx | 163 +++++ pkg/storaged/pages/mdraid.jsx | 345 ++++++++++ pkg/storaged/pages/nfs.jsx | 339 +++++++++ pkg/storaged/pages/other.jsx | 84 +++ pkg/storaged/pages/overview.jsx | 165 +++++ pkg/storaged/pages/stratis-blockdev.jsx | 121 ++++ pkg/storaged/pages/stratis-filesystem.jsx | 266 +++++++ pkg/storaged/pages/stratis-pool.jsx | 650 ++++++++++++++++++ pkg/storaged/pages/stratis-stopped-pool.jsx | 147 ++++ pkg/storaged/pages/swap.jsx | 101 +++ pkg/storaged/pages/unrecognized-data.jsx | 78 +++ pkg/storaged/resize.jsx | 70 ++ pkg/storaged/storage-controls.jsx | 31 +- pkg/storaged/storage-page.jsx | 66 ++ pkg/storaged/storage.scss | 8 +- pkg/storaged/storaged.jsx | 31 +- pkg/storaged/stratis-details.jsx | 2 +- pkg/storaged/utils.js | 2 +- pkg/storaged/utils/card.jsx | 32 + pkg/storaged/utils/desc.jsx | 33 + pkg/storaged/vgroup-details.jsx | 4 +- test/common/storagelib.py | 59 +- test/common/testlib.py | 8 + test/verify/check-storage-basic | 39 +- test/verify/check-storage-hidden | 55 +- test/verify/check-storage-ignored | 21 +- test/verify/check-storage-iscsi | 59 +- test/verify/check-storage-mounting | 285 ++++---- test/verify/check-storage-msdos | 29 +- test/verify/check-storage-multipath | 53 +- test/verify/check-storage-nfs | 131 ++-- test/verify/check-storage-partitions | 71 +- test/verify/check-storage-raid1 | 8 +- test/verify/check-storage-stratis | 455 ++++++------ test/verify/check-storage-swap | 27 +- test/verify/check-storage-unused | 7 +- test/verify/check-storage-used | 28 +- test/verify/storageutils.py | 16 + 61 files changed, 6162 insertions(+), 824 deletions(-) create mode 100644 pkg/storaged/actions.jsx create mode 100644 pkg/storaged/containers/encryption.jsx create mode 100644 pkg/storaged/containers/lvm2-logical-volume.jsx create mode 100644 pkg/storaged/containers/partition.jsx create mode 100644 pkg/storaged/create-pages.jsx delete mode 100644 pkg/storaged/overview.jsx create mode 100644 pkg/storaged/pages.jsx create mode 100644 pkg/storaged/pages/drive.jsx create mode 100644 pkg/storaged/pages/filesystem.jsx create mode 100644 pkg/storaged/pages/iscsi-session.jsx create mode 100644 pkg/storaged/pages/locked-encrypted-data.jsx create mode 100644 pkg/storaged/pages/lvm2-inactive-logical-volume.jsx create mode 100644 pkg/storaged/pages/lvm2-physical-volume.jsx create mode 100644 pkg/storaged/pages/lvm2-thin-pool-logical-volume.jsx create mode 100644 pkg/storaged/pages/lvm2-unsupported-logical-volume.jsx create mode 100644 pkg/storaged/pages/lvm2-volume-group.jsx create mode 100644 pkg/storaged/pages/mdraid-disk.jsx create mode 100644 pkg/storaged/pages/mdraid.jsx create mode 100644 pkg/storaged/pages/nfs.jsx create mode 100644 pkg/storaged/pages/other.jsx create mode 100644 pkg/storaged/pages/overview.jsx create mode 100644 pkg/storaged/pages/stratis-blockdev.jsx create mode 100644 pkg/storaged/pages/stratis-filesystem.jsx create mode 100644 pkg/storaged/pages/stratis-pool.jsx create mode 100644 pkg/storaged/pages/stratis-stopped-pool.jsx create mode 100644 pkg/storaged/pages/swap.jsx create mode 100644 pkg/storaged/pages/unrecognized-data.jsx create mode 100644 pkg/storaged/storage-page.jsx create mode 100644 pkg/storaged/utils/card.jsx create mode 100644 pkg/storaged/utils/desc.jsx create mode 100644 test/verify/storageutils.py diff --git a/pkg/storaged/actions.jsx b/pkg/storaged/actions.jsx new file mode 100644 index 000000000000..5b34b8c9e279 --- /dev/null +++ b/pkg/storaged/actions.jsx @@ -0,0 +1,74 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import client from "./client"; + +import { get_existing_passphrase, unlock_with_type } from "./crypto-keyslots.jsx"; // XXX +import { set_crypto_auto_option } from "./utils.js"; +import { dialog_open, PassInput } from "./dialog.jsx"; + +const _ = cockpit.gettext; + +export function unlock(block) { + const crypto = client.blocks_crypto[block.path]; + if (!crypto) + return; + + function unlock_with_passphrase() { + const crypto = client.blocks_crypto[block.path]; + if (!crypto) + return; + + dialog_open({ + Title: _("Unlock"), + Fields: [ + PassInput("passphrase", _("Passphrase"), {}) + ], + Action: { + Title: _("Unlock"), + action: function (vals) { + return (crypto.Unlock(vals.passphrase, {}) + .then(() => set_crypto_auto_option(block, true))); + } + } + }); + } + + return get_existing_passphrase(block, true).then(type => { + return (unlock_with_type(client, block, null, type) + .then(() => set_crypto_auto_option(block, true)) + .catch(() => unlock_with_passphrase())); + }); +} + +export function lock(block) { + const crypto = client.blocks_crypto[block.path]; + if (!crypto) + return; + + return crypto.Lock({}).then(() => set_crypto_auto_option(block, false)); +} + +export function std_lock_action(backing_block, content_block) { + if (backing_block == content_block) + return null; + + return { title: _("Lock"), action: () => lock(backing_block) }; +} diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js index dc31ccc12153..6dd9f43ec9c9 100644 --- a/pkg/storaged/client.js +++ b/pkg/storaged/client.js @@ -35,6 +35,8 @@ import vdo_monitor_py from "./vdo-monitor.py"; import stratis2_set_key_py from "./stratis2-set-key.py"; import stratis3_set_key_py from "./stratis3-set-key.py"; +import { create_pages } from "./create-pages.jsx"; + /* STORAGED CLIENT */ @@ -544,6 +546,25 @@ function update_indices() { client.blocks_partitions[path].sort(function (a, b) { return a.Offset - b.Offset }); } + client.iscsi_sessions_drives = { }; + client.drives_iscsi_session = { }; + for (path in client.drives) { + const block = client.drives_block[path]; + if (!block) + continue; + for (const session_path in client.iscsi_sessions) { + const session = client.iscsi_sessions[session_path]; + for (i = 0; i < block.Symlinks.length; i++) { + if (utils.decode_filename(block.Symlinks[i]).includes(session.data.target_name)) { + client.drives_iscsi_session[path] = session; + if (!client.iscsi_sessions_drives[session_path]) + client.iscsi_sessions_drives[session_path] = []; + client.iscsi_sessions_drives[session_path].push(client.drives[path]); + } + } + } + } + client.path_jobs = { }; function enter_job(job) { if (!job.Objects || !job.Objects.length) @@ -563,10 +584,15 @@ function update_indices() { } } -client.update = () => { - update_indices(); - client.path_warnings = find_warnings(client); - client.dispatchEvent("changed"); +client.update = (first_time) => { + if (first_time) + client.ready = true; + if (client.ready) { + update_indices(); + client.path_warnings = find_warnings(client); + create_pages(); + client.dispatchEvent("changed"); + } }; function init_model(callback) { @@ -705,7 +731,7 @@ function init_model(callback) { client.storaged_client.addEventListener('notify', () => client.update()); - client.update(); + client.update(true); callback(); }); }); @@ -801,7 +827,7 @@ function nfs_mounts() { if (lines.length >= 2) { self.entries = JSON.parse(lines[lines.length - 2]); self.fsys_sizes = { }; - client.dispatchEvent('changed'); + client.update(); } }) .catch(function (error) { @@ -824,11 +850,11 @@ function nfs_mounts() { .then(function (output) { const data = JSON.parse(output); self.fsys_sizes[path] = [(data[2] - data[1]) * data[0], data[2] * data[0]]; - client.dispatchEvent('changed'); + client.update(); }) .catch(function () { self.fsys_sizes[path] = [0, 0]; - client.dispatchEvent('changed'); + client.update(); }); return null; diff --git a/pkg/storaged/containers/encryption.jsx b/pkg/storaged/containers/encryption.jsx new file mode 100644 index 000000000000..a449bdd0b3b7 --- /dev/null +++ b/pkg/storaged/containers/encryption.jsx @@ -0,0 +1,251 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { useObject, useEvent } from "hooks"; +import * as python from "python.js"; +import * as timeformat from "timeformat.js"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { dialog_open, TextInput, PassInput } from "../dialog.jsx"; +import { block_name, encode_filename, decode_filename, parse_options, unparse_options, extract_option, edit_crypto_config } from "../utils.js"; +import { new_container } from "../pages.jsx"; +import luksmeta_monitor_hack_py from "../luksmeta-monitor-hack.py"; +import { is_mounted } from "../fsys-tab.jsx"; // XXX +import { StorageLink } from "../storage-controls.jsx"; +import { CryptoKeyslots } from "../crypto-keyslots.jsx"; + +const _ = cockpit.gettext; + +export function make_encryption_container(parent, block) { + return new_container({ + parent, + type_format: _("$0 (encrypted)"), // XXX - icon? + component: EncryptionContainer, + props: { block }, + }); +} + +function monitor_luks(block) { + const self = { + stop, + + luks_version: null, + slots: null, + slot_error: null, + max_slots: null, + }; + + cockpit.event_target(self); + + const dev = decode_filename(block.Device); + const channel = python.spawn(luksmeta_monitor_hack_py, [dev], { superuser: true }); + let buf = ""; + + channel.stream(output => { + buf += output; + const lines = buf.split("\n"); + buf = lines[lines.length - 1]; + if (lines.length >= 2) { + const data = JSON.parse(lines[lines.length - 2]); + self.slots = data.slots; + self.luks_version = data.version; + self.max_slots = data.max_slots; + self.dispatchEvent("changed"); + } + }); + + channel.catch(err => { + self.slots = []; + self.slot_error = err; + self.dispatchEvent("changed"); + }); + + function stop() { + channel.close(); + } + + return self; +} + +function parse_tag_mtime(tag) { + if (tag && tag.indexOf("1:") == 0) { + try { + const parts = tag.split("-")[1].split("."); + // s:ns → ms + const mtime = parseInt(parts[0]) * 1000 + parseInt(parts[1]) * 1e-6; + return cockpit.format(_("Last modified: $0"), timeformat.dateTime(mtime)); + } catch { + return null; + } + } else + return null; +} + +function monitor_mtime(path) { + const self = { + stop, + + mtime: 0 + }; + + cockpit.event_target(self); + + let file = null; + if (path) { + file = cockpit.file(path, { superuser: true }); + file.watch((_, tag) => { self.mtime = parse_tag_mtime(tag); self.dispatchEvent("changed") }, + { read: false }); + } + + function stop() { + if (file) + file.close(); + } + + return self; +} + +const EncryptionContainer = ({ container, block }) => { + const luks_info = useObject(() => monitor_luks(block), + m => m.stop(), + [block]); + useEvent(luks_info, "changed"); + + let old_options, passphrase_path; + const old_config = block.Configuration.find(c => c[0] == "crypttab"); + if (old_config) { + old_options = (decode_filename(old_config[1].options.v) + .split(",") + .filter(function (s) { return s.indexOf("x-parent") !== 0 }) + .join(",")); + passphrase_path = decode_filename(old_config[1]["passphrase-path"].v); + } + + const stored_passphrase_info = useObject(() => monitor_mtime(passphrase_path), + m => m.stop(), + [passphrase_path]); + useEvent(stored_passphrase_info, "changed"); + + const split_options = parse_options(old_options); + let opt_noauto = extract_option(split_options, "noauto"); + const extra_options = unparse_options(split_options); + + function edit_stored_passphrase() { + edit_crypto_config(block, function (config, commit) { + dialog_open({ + Title: _("Stored passphrase"), + Fields: [ + PassInput("passphrase", _("Stored passphrase"), + { + value: (config && config['passphrase-contents'] + ? decode_filename(config['passphrase-contents'].v) + : "") + }) + ], + Action: { + Title: _("Save"), + action: function (vals) { + config["passphrase-contents"] = { + t: 'ay', + v: encode_filename(vals.passphrase) + }; + delete config["passphrase-path"]; + return commit(); + } + } + }); + }); + } + + function edit_options() { + const fsys_config = client.blocks_crypto[block.path]?.ChildConfiguration.find(c => c[0] == "fstab"); + const content_block = client.blocks_cleartext[block.path]; + const is_fsys = fsys_config || (content_block && content_block.IdUsage == "filesystem"); + + edit_crypto_config(block, function (config, commit) { + dialog_open({ + Title: _("Encryption options"), + Fields: [ + TextInput("options", "", { value: extra_options }), + ], + isFormHorizontal: false, + Action: { + Title: _("Save"), + action: function (vals) { + let opts = []; + if (is_fsys && content_block) + opt_noauto = !is_mounted(client, content_block); + if (opt_noauto) + opts.push("noauto"); + opts = opts.concat(parse_options(vals.options)); + config.options = { + t: 'ay', + v: encode_filename(unparse_options(opts)) + }; + return commit(); + } + } + }); + }); + } + + const cleartext = client.blocks_cleartext[block.path]; + + const option_parts = []; + if (extra_options) + option_parts.push(extra_options); + const options = option_parts.join(", "); + + return ( + + + + + { luks_info.luks_version ? "LUKS" + luks_info.luks_version : "-" } + + + {cleartext ? block_name(cleartext) : "-"} + + + + { passphrase_path ? stored_passphrase_info.mtime || _("yes") : _("none") } + {_("edit")} + + + + + { options || _("none") } + {_("edit")} + + + + + + ); +}; diff --git a/pkg/storaged/containers/lvm2-logical-volume.jsx b/pkg/storaged/containers/lvm2-logical-volume.jsx new file mode 100644 index 000000000000..a63744c65cf7 --- /dev/null +++ b/pkg/storaged/containers/lvm2-logical-volume.jsx @@ -0,0 +1,206 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; +import { StorageButton, StorageLink } from "../storage-controls.jsx"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { check_unused_space, get_resize_info, grow_dialog, shrink_dialog } from "../resize.jsx"; +import { new_container, 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 ( + }> + + + + + {lvol.Name} + + lvol_rename(lvol)}> + {_("edit")} + + + + + { (layout && layout != "linear") && + + } + + { !unused_space && + + {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..0fb22e2f8ec3 --- /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 delete_partition(block) { + 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) + ] + }); +} + +export function make_partition_container(parent, block) { + const unused_space_warning = check_unused_space(block.path); + + 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_partition(block), 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..48905701feac --- /dev/null +++ b/pkg/storaged/create-pages.jsx @@ -0,0 +1,168 @@ +/* + * 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_swap_page } from "./pages/swap.jsx"; + +import { make_partition_container, delete_partition } 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"), + columns: [ + null, + null, + fmt_size(partition.size), + ], + actions: [ + { title: _("Delete"), action: () => delete_partition(partition.block) }, + ] + }); + 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]; + const block_swap = client.blocks_swap[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 if (block_swap || (content_block && content_block.IdUsage == "other" && content_block.IdType == "swap")) { + make_swap_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 f33d3fc1c267..aa0fa1906327 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 ff7e5dae9be3..c657abe9bac6 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/multipath.jsx b/pkg/storaged/multipath.jsx index dd4b38df6869..140d240fbe72 100644 --- a/pkg/storaged/multipath.jsx +++ b/pkg/storaged/multipath.jsx @@ -19,8 +19,9 @@ import cockpit from "cockpit"; import React from "react"; + +import { StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; import { Alert, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; -import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js"; import { get_multipathd_service } from "./utils.js"; import { dialog_open } from "./dialog.jsx"; @@ -64,14 +65,12 @@ export class MultipathAlert extends React.Component { if (multipath_broken && !multipathd_running) return ( - - - {_("Start multipath")}} - title={_("There are devices with multiple paths on the system, but the multipath service is not running.")} - /> - - + + {_("Start multipath")}} + title={_("There are devices with multiple paths on the system, but the multipath service is not running.")} + /> + ); return null; } 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..7280158cdeeb --- /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 = cockpit.format(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, key) { + 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, + 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, rows.length)); + 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, rows.length)); + } + } + + 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..7066e8eda162 --- /dev/null +++ b/pkg/storaged/pages/drive.jsx @@ -0,0 +1,141 @@ +/* + * 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) { + let block = client.drives_block[drive.path]; + + if (!block) { + // A drive without a primary block device might be + // a unconfigured multipath device. Try to hobble + // along here by arbitrarily picking one of the + // multipath devices. + block = client.drives_multipath_blocks[drive.path][0]; + } + + if (!block) + return; + + const drive_page = new_page({ + location: ["drive", block_location(block)], + parent, + name: drive_name(drive), + columns: [ + _("Drive"), + block_name(block), + block.Size > 0 ? fmt_size(block.Size) : null + ], + component: DrivePage, + props: { drive } + }); + + if (block.Size > 0) + make_block_pages(drive_page, block, null); +} + +const DrivePage = ({ page, drive }) => { + const block = client.drives_block[drive.path]; + const drive_ata = client.drives_ata[drive.path]; + const multipath_blocks = client.drives_multipath_blocks[drive.path]; + const is_partitioned = block && !!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 && 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 && + + } + + + + + { block && block.Size > 0 + ? ( + + ) + : null + } + + ); +}; diff --git a/pkg/storaged/pages/filesystem.jsx b/pkg/storaged/pages/filesystem.jsx new file mode 100644 index 000000000000..23db57339996 --- /dev/null +++ b/pkg/storaged/pages/filesystem.jsx @@ -0,0 +1,439 @@ +/* + * 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")} + + + + + + + + + + { mismount_warning && + + + + } + + + + ); +}; diff --git a/pkg/storaged/pages/iscsi-session.jsx b/pkg/storaged/pages/iscsi-session.jsx new file mode 100644 index 000000000000..8a62484d3f3c --- /dev/null +++ b/pkg/storaged/pages/iscsi-session.jsx @@ -0,0 +1,85 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { PageChildrenCard, new_page, page_type, ActionButtons } from "../pages.jsx"; + +import { make_drive_page } from "./drive.jsx"; + +const _ = cockpit.gettext; + +async function disconnect(session, goto_page) { + const loc = cockpit.location; + await session.Logout({ 'node.startup': { t: 's', v: "manual" } }); + loc.go(goto_page.location); +} + +export function make_iscsi_session_page(parent, session) { + const p = new_page({ + location: ["iscsi", session.data.target_name], + parent, + name: session.data.target_name, + columns: [ + _("iSCSI portal"), + session.data.persistent_address + ":" + session.data.persistent_port, + null, + ], + component: ISCSISessionPage, + props: { session }, + actions: [ + { + title: _("Disconnect"), + action: () => disconnect(session, parent), + danger: true + }, + ] + }); + + if (client.iscsi_sessions_drives[session.path]) + client.iscsi_sessions_drives[session.path].forEach(d => make_drive_page(p, d)); +} + +const ISCSISessionPage = ({ page, session }) => { + return ( + + + }> + + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/locked-encrypted-data.jsx b/pkg/storaged/pages/locked-encrypted-data.jsx new file mode 100644 index 000000000000..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..7ef1c89eeb58 --- /dev/null +++ b/pkg/storaged/pages/nfs.jsx @@ -0,0 +1,339 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { + dialog_open, TextInput, ComboBox, CheckBoxes, + StopProcessesMessage, stop_processes_danger_message +} from "../dialog.jsx"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { StorageUsageBar } from "../storage-controls.jsx"; +import { new_page, page_type, ActionButtons } from "../pages.jsx"; +import { parse_options, unparse_options, extract_option } from "../utils.js"; + +const _ = cockpit.gettext; + +function nfs_busy_dialog(client, dialog_title, entry, error, action_title, action) { + function show(users) { + if (users.length === 0) { + dialog_open({ + Title: dialog_title, + Body: error.toString() + }); + } else { + dialog_open({ + Title: dialog_title, + Teardown: , + Action: { + DangerButton: true, + Danger: stop_processes_danger_message(users), + Title: action_title, + action: function () { + return action(users); + } + } + }); + } + } + + client.nfs.entry_users(entry) + .then(function (users) { + show(users); + }) + .catch(function () { + show([]); + }); +} + +function get_exported_directories(server) { + return cockpit.spawn(["showmount", "-e", "--no-headers", server], { err: "message" }) + .then(function (output) { + const dirs = []; + output.split("\n").forEach(function (line) { + const d = line.split(" ")[0]; + if (d) + dirs.push(d); + }); + return dirs; + }); +} + +export function nfs_fstab_dialog(client, entry) { + const mount_options = entry ? entry.fields[3] : "defaults"; + const split_options = parse_options(mount_options == "defaults" ? "" : mount_options); + const opt_auto = !extract_option(split_options, "noauto"); + const opt_ro = extract_option(split_options, "ro"); + const extra_options = unparse_options(split_options); + + function mounting_options(vals) { + let opts = []; + if (!vals.mount_options.auto) + opts.push("noauto"); + if (vals.mount_options.ro) + opts.push("ro"); + if (vals.mount_options.extra !== false) + opts = opts.concat(parse_options(vals.mount_options.extra)); + return unparse_options(opts); + } + + function show(busy) { + let alert = null; + if (busy) + alert = <> + +
+ ; + + let server_to_check = null; + let server_check_timeout = null; + + function check_server(dlg, server, delay) { + if (server_check_timeout) + window.clearTimeout(server_check_timeout); + server_to_check = server; + server_check_timeout = window.setTimeout(() => { + server_check_timeout = null; + dlg.set_options("remote", { choices: [] }); + get_exported_directories(server).then(choices => { + if (server == server_to_check) + dlg.set_options("remote", { choices }); + }); + }, delay); + } + + const dlg = dialog_open({ + Title: entry ? _("NFS mount") : _("New NFS mount"), + Body: alert, + Fields: [ + TextInput("server", _("Server address"), + { + value: entry ? entry.fields[0].split(":")[0] : "", + validate: function (val) { + if (val === "") + return _("Server cannot be empty."); + }, + disabled: busy + }), + ComboBox("remote", _("Path on server"), + { + value: entry ? entry.fields[0].split(":")[1] : "", + validate: function (val) { + if (val === "") + return _("Path on server cannot be empty."); + if (val[0] !== "/") + return _("Path on server must start with \"/\"."); + }, + disabled: busy, + choices: [], + }), + TextInput("dir", _("Local mount point"), + { + value: entry ? entry.fields[1] : "", + validate: function (val) { + if (val === "") + return _("Mount point cannot be empty."); + if (val[0] !== "/") + return _("Mount point must start with \"/\"."); + }, + disabled: busy + }), + CheckBoxes("mount_options", _("Mount options"), + { + fields: [ + { title: _("Mount at boot"), tag: "auto" }, + { title: _("Mount read only"), tag: "ro" }, + { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" }, + ], + value: { + auto: opt_auto, + ro: opt_ro, + extra: extra_options === "" ? false : extra_options + } + }, + ), + ], + update: (dlg, vals, trigger) => { + if (trigger === "server") + check_server(dlg, vals.server, 500); + }, + Action: { + Title: entry ? _("Save") : _("Add"), + action: function (vals) { + const location = cockpit.location; + const fields = [vals.server + ":" + vals.remote, + vals.dir, + entry ? entry.fields[2] : "nfs", + mounting_options(vals) || "defaults"]; + if (entry) { + return client.nfs.update_entry(entry, fields) + .then(function () { + if (entry.fields[0] != fields[0] || + entry.fields[1] != fields[1]) + location.go(["nfs", fields[0], fields[1]]); + }); + } else + return client.nfs.add_entry(fields); + } + } + }); + + if (entry && !busy) + check_server(dlg, entry.fields[0].split(":")[0], 0); + } + + if (entry) { + client.nfs.entry_users(entry) + .then(function (users) { + show(users.length > 0); + }) + .catch(function () { + show(false); + }); + } else + show(false); +} + +function checked(error_title, promise) { + promise.catch(error => { + dialog_open({ + Title: error_title, + Body: error.toString() + }); + }); +} + +function mount(client, entry) { + checked("Could not mount the filesystem", + client.nfs.mount_entry(entry)); +} + +function unmount(client, entry) { + const location = cockpit.location; + client.nfs.unmount_entry(entry) + .then(function () { + if (!entry.fstab) + location.go("/"); + }) + .catch(function (error) { + nfs_busy_dialog(client, + _("Unable to unmount filesystem"), + entry, error, + _("Stop and unmount"), + function (users) { + return client.nfs.stop_and_unmount_entry(users, entry) + .then(function () { + if (!entry.fstab) + location.go("/"); + }); + }); + }); +} + +function edit(client, entry) { + nfs_fstab_dialog(client, entry); +} + +function remove(client, entry) { + const location = cockpit.location; + client.nfs.remove_entry(entry) + .then(function () { + location.go("/"); + }) + .catch(function (error) { + nfs_busy_dialog(client, + _("Unable to remove mount"), + entry, error, + _("Stop and remove"), + function (users) { + return client.nfs.stop_and_remove_entry(users, entry) + .then(function () { + location.go("/"); + }); + }); + }); +} + +const NfsEntryUsageBar = ({ entry, not_mounted_text }) => { + if (entry.mounted) + return ; + else + return not_mounted_text; +}; + +export function make_nfs_page(parent, entry) { + const remote = entry.fields[0]; + const local = entry.fields[1]; + let mount_point = local; + if (!entry.mounted) + mount_point += " " + _("(not mounted)"); + + new_page({ + location: ["nfs", remote, local], + parent, + name: remote, + columns: [ + _("NFS mount"), + mount_point, + , + ], + component: NfsPage, + props: { entry }, + actions: [ + (entry.mounted + ? { title: _("Unmount"), action: () => unmount(client, entry) } + : { title: _("Mount"), action: () => mount(client, entry) }), + (entry.fstab + ? { title: _("Edit"), action: () => edit(client, entry) } + : null), + (entry.fstab + ? { title: _("Remove"), action: () => remove(client, entry), danger: true } + : null), + ] + }); +} + +const NfsPage = ({ page, entry }) => { + return ( + + + }> + + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/other.jsx b/pkg/storaged/pages/other.jsx new file mode 100644 index 000000000000..932ea23ce460 --- /dev/null +++ b/pkg/storaged/pages/other.jsx @@ -0,0 +1,84 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { StorageButton } from "../storage-controls.jsx"; +import { PageChildrenCard, new_page, page_type, block_location } from "../pages.jsx"; +import { block_name, fmt_size } from "../utils.js"; +import { format_disk } from "../content-views.jsx"; // XXX + +import { make_block_pages } from "../create-pages.jsx"; + +const _ = cockpit.gettext; + +export function make_other_page(parent, block) { + const p = new_page({ + location: ["other", block_location(block)], + parent, + name: block_location(block), + columns: [ + _("Block device"), + block_name(block), + fmt_size(block.Size) + ], + component: OtherPage, + props: { block } + }); + + make_block_pages(p, block, null); +} + +const OtherPage = ({ page, block }) => { + const is_partitioned = !!client.blocks_ptable[block.path]; + + const actions = + format_disk(client, block)} + excuse={block.ReadOnly ? _("Device is read-only") : null}> + {_("Create partition table")} + ; + + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/overview.jsx b/pkg/storaged/pages/overview.jsx new file mode 100644 index 000000000000..0ba6ab074eb0 --- /dev/null +++ b/pkg/storaged/pages/overview.jsx @@ -0,0 +1,165 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { install_dialog } from "cockpit-components-install-dialog.jsx"; + +import { Card, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; + +import { StoragePlots } from "../plot.jsx"; +import { StorageMenuItem, StorageBarMenu } from "../storage-controls.jsx"; +import { dialog_open } from "../dialog.jsx"; +import { StorageLogsPanel } from "../logs-panel.jsx"; + +import { create_mdraid } from "../mdraids-panel.jsx"; // XXX +import { create_vgroup } from "../vgroups-panel.jsx"; // XXX +import { create_stratis_pool } from "../stratis-panel.jsx"; // XXX +import { iscsi_change_name, iscsi_discover } from "../iscsi-panel.jsx"; // XXX +import { get_other_devices } from "../utils.js"; // XXX + +import { new_page, PageChildrenCard } from "../pages.jsx"; +import { make_drive_page } from "./drive.jsx"; +import { make_lvm2_volume_group_page } from "./lvm2-volume-group.jsx"; +import { make_mdraid_page } from "./mdraid.jsx"; +import { make_stratis_pool_page } from "./stratis-pool.jsx"; +import { make_stratis_stopped_pool_page } from "./stratis-stopped-pool.jsx"; +import { make_nfs_page, nfs_fstab_dialog } from "./nfs.jsx"; +import { make_iscsi_session_page } from "./iscsi-session.jsx"; +import { make_other_page } from "./other.jsx"; + +const _ = cockpit.gettext; + +export function make_overview_page() { + const overview_page = new_page({ + location: [], + name: _("Storage"), + component: OverviewPage + }); + + Object.keys(client.iscsi_sessions).forEach(p => make_iscsi_session_page(overview_page, client.iscsi_sessions[p])); + Object.keys(client.drives).forEach(p => { + if (!client.drives_iscsi_session[p]) + make_drive_page(overview_page, client.drives[p]); + }); + Object.keys(client.vgroups).forEach(p => make_lvm2_volume_group_page(overview_page, client.vgroups[p])); + Object.keys(client.mdraids).forEach(p => make_mdraid_page(overview_page, client.mdraids[p])); + Object.keys(client.stratis_pools).map(p => make_stratis_pool_page(overview_page, client.stratis_pools[p])); + Object.keys(client.stratis_manager.StoppedPools).map(uuid => make_stratis_stopped_pool_page(overview_page, uuid)); + client.nfs.entries.forEach(e => make_nfs_page(overview_page, e)); + get_other_devices(client).map(p => make_other_page(overview_page, client.blocks[p])); +} + +const OverviewPage = ({ page, plot_state }) => { + function menu_item(feature, title, action) { + const feature_enabled = !feature || feature.is_enabled(); + const required_package = feature && feature.package; + + if (!feature_enabled && !(required_package && client.features.packagekit)) + return null; + + function install_then_action() { + if (!feature_enabled) { + install_dialog(required_package, feature.dialog_options).then( + () => { + feature.enable() + .then(action) + .catch(error => { + dialog_open({ + Title: _("Error"), + Body: error.toString() + }); + }); + }, + () => null /* ignore cancel */); + } else { + action(); + } + } + + return {title}; + } + + const lvm2_feature = { + is_enabled: () => client.features.lvm2 + }; + + const stratis_feature = { + is_enabled: () => client.features.stratis, + package: client.get_config("stratis_package", false), + enable: () => { + return cockpit.spawn(["systemctl", "start", "stratisd"], { superuser: true }) + .then(() => client.stratis_start()); + }, + + dialog_options: { + title: _("Install Stratis support"), + text: _("The $0 package must be installed to create Stratis pools.") + } + }; + + const nfs_feature = { + is_enabled: () => client.features.nfs, + package: client.get_config("nfs_client_package", false), + enable: () => { + client.features.nfs = true; + client.nfs.start(); + return Promise.resolve(); + }, + + dialog_options: { + title: _("Install NFS support") + } + }; + + const iscsi_feature = { + is_enabled: () => client.features.iscsi, + }; + + const menu_items = [ + menu_item(null, _("Create RAID device"), () => create_mdraid(client)), + menu_item(lvm2_feature, _("Create LVM2 volume group"), () => create_vgroup(client)), + menu_item(stratis_feature, _("Create Stratis pool"), () => create_stratis_pool(client)), + menu_item(nfs_feature, _("New NFS mount"), () => nfs_fstab_dialog(client, null)), + menu_item(iscsi_feature, _("Change iSCSI initiater name"), () => iscsi_change_name(client)), + menu_item(iscsi_feature, _("Add iSCSI portal"), () => iscsi_discover(client)), + ].filter(item => item !== null); + + const actions = ; + + return ( + + + + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/stratis-blockdev.jsx b/pkg/storaged/pages/stratis-blockdev.jsx new file mode 100644 index 000000000000..454236caff65 --- /dev/null +++ b/pkg/storaged/pages/stratis-blockdev.jsx @@ -0,0 +1,121 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; +import { Card, CardHeader, CardTitle, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; +import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { + ParentPageLink, PageContainerStackItems, + new_page, block_location, ActionButtons, page_type, + register_crossref, +} from "../pages.jsx"; +import { format_dialog } from "../format-dialog.jsx"; +import { block_name, fmt_size } from "../utils.js"; +import { std_lock_action } from "../actions.jsx"; + +const _ = cockpit.gettext; + +export function make_stratis_blockdev_page(parent, backing_block, content_block, container) { + const blockdev = client.blocks_stratis_blockdev[content_block.path]; + const pool = blockdev && client.stratis_pools[blockdev.Pool]; + const stopped_pool = client.blocks_stratis_stopped_pool[content_block.path]; + + const p = new_page({ + location: [block_location(backing_block)], + parent, + container, + name: block_name(backing_block), + columns: [ + _("Stratis block device"), + pool ? pool.Name : stopped_pool, + fmt_size(backing_block.Size) + ], + component: StratisBlockdevPage, + props: { backing_block, content_block, pool, stopped_pool }, + actions: [ + std_lock_action(backing_block, content_block), + { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + ] + }); + + let desc; + if (blockdev && blockdev.Tier == 0) + desc = cockpit.format(_("$0 data"), + fmt_size(Number(blockdev.TotalPhysicalSize))); + else if (blockdev && blockdev.Tier == 1) + desc = cockpit.format(_("$0 cache"), + fmt_size(Number(blockdev.TotalPhysicalSize))); + else + desc = cockpit.format(_("$0 of unknown tier"), + fmt_size(backing_block.Size)); + + if (pool || stopped_pool) { + register_crossref({ + key: pool || stopped_pool, + page: p, + size: desc, + actions: [], + }); + } +} + +export const StratisBlockdevPage = ({ page, backing_block, content_block, pool, stopped_pool }) => { + const pool_name = pool ? pool.Name : stopped_pool; + const pool_uuid = pool ? pool.Uuid : stopped_pool; + + return ( + + + + }}> + {page_type(page)} + + + + + {_("Stored on")} + + + + + + {_("Stratis pool")} + + {(pool || stopped_pool) + ? + : "-" + } + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/stratis-filesystem.jsx b/pkg/storaged/pages/stratis-filesystem.jsx new file mode 100644 index 000000000000..bea175e0169d --- /dev/null +++ b/pkg/storaged/pages/stratis-filesystem.jsx @@ -0,0 +1,266 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { + dialog_open, TextInput, CheckBoxes, SelectOne, BlockingMessage, TeardownMessage, + init_active_usage_processes, +} from "../dialog.jsx"; +import { StorageUsageBar } from "../storage-controls.jsx"; +import { + ParentPageLink, PageContainerStackItems, + new_page, ActionButtons, page_type, +} from "../pages.jsx"; +import { is_valid_mount_point, is_mounted, mounting_dialog, get_fstab_config } from "../fsys-tab.jsx"; // XXX +import { fmt_size, get_active_usage, teardown_active_usage } from "../utils.js"; +import { std_reply } from "../stratis-utils.js"; +import { validate_fs_name, set_mount_options, destroy_filesystem } from "./stratis-pool.jsx"; // XXX +import { mount_explanation } from "../format-dialog.jsx"; +import { MountPoint, MismountAlert, check_mismounted_fsys } from "./filesystem.jsx"; + +const _ = cockpit.gettext; + +export function make_stratis_filesystem_page(parent, pool, fsys, + offset, forced_options, managed_fsys_sizes) { + const filesystems = client.stratis_pool_filesystems[pool.path]; + const stats = client.stratis_pool_stats[pool.path]; + const block = client.slashdevs_block[fsys.Devnode]; + + if (!block) + return; + + const fstab_config = get_fstab_config(block); + const [, mount_point] = fstab_config; + const fs_is_mounted = is_mounted(client, block); + + const mismount_warning = check_mismounted_fsys(block, block, fstab_config); + + function mount() { + return mounting_dialog(client, block, "mount", forced_options); + } + + function unmount() { + return mounting_dialog(client, block, "unmount", forced_options); + } + + function rename_fsys() { + dialog_open({ + Title: _("Rename filesystem"), + Fields: [ + TextInput("name", _("Name"), + { + value: fsys.Name, + validate: name => validate_fs_name(fsys, name, filesystems) + }) + ], + Action: { + Title: _("Rename"), + action: function (vals) { + return fsys.SetName(vals.name).then(std_reply); + } + } + }); + } + + function snapshot_fsys() { + if (managed_fsys_sizes && stats.pool_free < Number(fsys.Size)) { + dialog_open({ + Title: _("Not enough space"), + Body: cockpit.format(_("There is not enough space in the pool to make a snapshot of this filesystem. At least $0 are required but only $1 are available."), + fmt_size(Number(fsys.Size)), fmt_size(stats.pool_free)) + }); + return; + } + + dialog_open({ + Title: cockpit.format(_("Create a snapshot of filesystem $0"), fsys.Name), + Fields: [ + TextInput("name", _("Name"), + { + value: "", + validate: name => validate_fs_name(null, name, filesystems) + }), + TextInput("mount_point", _("Mount point"), + { + validate: (val, values, variant) => { + return is_valid_mount_point(client, null, val, variant == "nomount"); + } + }), + CheckBoxes("mount_options", _("Mount options"), + { + value: { + ro: false, + extra: false + }, + fields: [ + { title: _("Mount read only"), tag: "ro" }, + { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" }, + ] + }), + SelectOne("at_boot", _("At boot"), + { + value: "nofail", + explanation: mount_explanation.nofail, + choices: [ + { + value: "local", + title: _("Mount before services start"), + }, + { + value: "nofail", + title: _("Mount without waiting, ignore failure"), + }, + { + value: "netdev", + title: _("Mount after network becomes available, ignore failure"), + }, + { + value: "never", + title: _("Do not mount"), + }, + ] + }), + ], + update: function (dlg, vals, trigger) { + if (trigger == "at_boot") + dlg.set_options("at_boot", { explanation: mount_explanation[vals.at_boot] }); + }, + Action: { + Title: _("Create snapshot and mount"), + Variants: [{ tag: "nomount", Title: _("Create snapshot only") }], + action: function (vals) { + return pool.SnapshotFilesystem(fsys.path, vals.name) + .then(std_reply) + .then(result => { + if (result[0]) + return set_mount_options(result[1], vals, forced_options); + else + return Promise.resolve(); + }); + } + } + }); + } + + function delete_fsys() { + const usage = get_active_usage(client, block.path, _("delete")); + + if (usage.Blocking) { + dialog_open({ + Title: cockpit.format(_("$0 is in use"), + fsys.Name), + Body: BlockingMessage(usage) + }); + return; + } + + dialog_open({ + Title: cockpit.format(_("Confirm deletion of $0"), fsys.Name), + Teardown: TeardownMessage(usage), + Action: { + Danger: _("Deleting a filesystem will delete all data in it."), + Title: _("Delete"), + action: function () { + return teardown_active_usage(client, usage) + .then(() => destroy_filesystem(fsys)); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); + } + + let mp_text; + if (mount_point && fs_is_mounted) + mp_text = mount_point; + else if (mount_point && !fs_is_mounted) + mp_text = mount_point + " " + _("(not mounted)"); + else + mp_text = _("(not mounted)"); + + new_page({ + location: ["pool", pool.Name, fsys.Name], + parent, + name: fsys.Name, + columns: [ + _("Stratis filesystem"), + mp_text, + (!managed_fsys_sizes + ? + : ) + ], + has_warning: !!mismount_warning, + component: StratisFilesystemPage, + props: { pool, fsys, fstab_config, forced_options, managed_fsys_sizes, mismount_warning }, + actions: [ + (fs_is_mounted + ? { title: _("Unmount"), action: unmount } + : { title: _("Mount"), action: mount }), + { title: _("Rename"), action: rename_fsys }, + { title: _("Snapshot"), action: snapshot_fsys }, + { title: _("Delete"), action: delete_fsys, danger: true }, + ] + }); +} + +const StratisFilesystemPage = ({ + page, pool, fsys, fstab_config, forced_options, managed_fsys_sizes, mismount_warning, +}) => { + const block = client.slashdevs_block[fsys.Devnode]; + + return ( + + + }> + + + + + + + + + + + + { mismount_warning && + + + + } + + + + ); +}; diff --git a/pkg/storaged/pages/stratis-pool.jsx b/pkg/storaged/pages/stratis-pool.jsx new file mode 100644 index 000000000000..fe9ed85e0c20 --- /dev/null +++ b/pkg/storaged/pages/stratis-pool.jsx @@ -0,0 +1,650 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; +import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +import { StorageButton, StorageUsageBar, StorageLink } from "../storage-controls.jsx"; +import { PageChildrenCard, PageCrossrefCard, ActionButtons, new_page, page_type, get_crossrefs } from "../pages.jsx"; +import { + fmt_size, get_active_usage, teardown_active_usage, for_each_async, + get_available_spaces, prepare_available_spaces, + reload_systemd, encode_filename, decode_filename, +} from "../utils.js"; +import { fmt_to_fragments } from "utils.jsx"; + +import { + dialog_open, SelectSpaces, TextInput, PassInput, CheckBoxes, SelectOne, SizeSlider, + BlockingMessage, TeardownMessage, + init_active_usage_processes +} from "../dialog.jsx"; + +import { validate_url, get_tang_adv } from "../crypto-keyslots.jsx"; // XXX +import { is_valid_mount_point } from "../fsys-tab.jsx"; // XXX +import { std_reply, with_keydesc, with_stored_passphrase, confirm_tang_trust, get_unused_keydesc } from "../stratis-utils.js"; +import { mount_explanation } from "../format-dialog.jsx"; + +import { make_stratis_filesystem_page } from "./stratis-filesystem.jsx"; + +const _ = cockpit.gettext; + +const fsys_min_size = 512 * 1024 * 1024; + +function teardown_block(block) { + return for_each_async(block.Configuration, c => block.RemoveConfigurationItem(c, {})); +} + +export function destroy_filesystem(fsys) { + const block = client.slashdevs_block[fsys.Devnode]; + const pool = client.stratis_pools[fsys.Pool]; + + return teardown_block(block).then(() => pool.DestroyFilesystems([fsys.path]).then(std_reply)); +} + +function destroy_pool(pool) { + return for_each_async(client.stratis_pool_filesystems[pool.path], fsys => destroy_filesystem(fsys)) + .then(() => client.stratis_manager.DestroyPool(pool.path).then(std_reply)); +} + +export function validate_fs_name(fsys, name, filesystems) { + if (name == "") + return _("Name can not be empty."); + if (!fsys || name != fsys.Name) { + for (const fs of filesystems) { + if (fs.Name == name) + return _("A filesystem with this name exists already in this pool."); + } + } +} + +export function validate_pool_name(pool, name) { + if (name == "") + return _("Name can not be empty."); + if ((!pool || name != pool.Name) && client.stratis_poolnames_pool[name]) + return _("A pool with this name exists already."); +} + +export function set_mount_options(path, vals, forced_options) { + let mount_options = []; + + if (vals.variant == "nomount" || vals.at_boot == "never") + mount_options.push("noauto"); + if (vals.mount_options.ro) + mount_options.push("ro"); + if (vals.at_boot == "never") + mount_options.push("x-cockpit-never-auto"); + if (vals.at_boot == "nofail") + mount_options.push("nofail"); + if (vals.at_boot == "netdev") + mount_options.push("_netdev"); + if (vals.mount_options.extra) + mount_options.push(vals.mount_options.extra); + + mount_options = mount_options.concat(forced_options); + + let mount_point = vals.mount_point; + if (mount_point == "") + return Promise.resolve(); + if (mount_point[0] != "/") + mount_point = "/" + mount_point; + + const config = + ["fstab", + { + dir: { t: 'ay', v: encode_filename(mount_point) }, + type: { t: 'ay', v: encode_filename("auto") }, + opts: { t: 'ay', v: encode_filename(mount_options.join(",") || "defaults") }, + freq: { t: 'i', v: 0 }, + passno: { t: 'i', v: 0 }, + } + ]; + + function udisks_block_for_stratis_fsys() { + const fsys = client.stratis_filesystems[path]; + return fsys && client.slashdevs_block[fsys.Devnode]; + } + + return client.wait_for(udisks_block_for_stratis_fsys) + .then(block => { + // HACK - need a explicit "change" event + return block.Rescan({}) + .then(() => { + return client.wait_for(() => client.blocks_fsys[block.path]) + .then(fsys => { + return block.AddConfigurationItem(config, {}) + .then(reload_systemd) + .then(() => { + if (vals.variant != "nomount") + return client.mount_at(block, mount_point); + else + return Promise.resolve(); + }); + }); + }); + }); +} + +function create_fs(pool) { + const filesystems = client.stratis_pool_filesystems[pool.path]; + const stats = client.stratis_pool_stats[pool.path]; + const forced_options = ["x-systemd.requires=stratis-fstab-setup@" + pool.Uuid + ".service"]; + const managed_fsys_sizes = client.features.stratis_managed_fsys_sizes && !pool.Overprovisioning; + + dialog_open({ + Title: _("Create filesystem"), + Fields: [ + TextInput("name", _("Name"), + { + validate: name => validate_fs_name(null, name, filesystems) + }), + SizeSlider("size", _("Size"), + { + visible: () => managed_fsys_sizes, + min: fsys_min_size, + max: stats.pool_free, + round: 512 + }), + TextInput("mount_point", _("Mount point"), + { + validate: (val, values, variant) => { + return is_valid_mount_point(client, null, val, variant == "nomount"); + } + }), + CheckBoxes("mount_options", _("Mount options"), + { + value: { + ro: false, + extra: false + }, + fields: [ + { title: _("Mount read only"), tag: "ro" }, + { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" }, + ] + }), + SelectOne("at_boot", _("At boot"), + { + value: "nofail", + explanation: mount_explanation.nofail, + choices: [ + { + value: "local", + title: _("Mount before services start"), + }, + { + value: "nofail", + title: _("Mount without waiting, ignore failure"), + }, + { + value: "netdev", + title: _("Mount after network becomes available, ignore failure"), + }, + { + value: "never", + title: _("Do not mount"), + }, + ] + }), + ], + update: function (dlg, vals, trigger) { + if (trigger == "at_boot") + dlg.set_options("at_boot", { explanation: mount_explanation[vals.at_boot] }); + }, + Action: { + Title: _("Create and mount"), + Variants: [{ tag: "nomount", Title: _("Create only") }], + action: function (vals) { + return client.stratis_create_filesystem(pool, vals.name, vals.size) + .then(std_reply) + .then(result => { + if (result[0]) + return set_mount_options(result[1][0][0], vals, forced_options); + else + return Promise.resolve(); + }); + } + } + }); +} + +function delete_pool(pool) { + const location = cockpit.location; + const usage = get_active_usage(client, pool.path, _("delete")); + + if (usage.Blocking) { + dialog_open({ + Title: cockpit.format(_("$0 is in use"), + pool.Name), + Body: BlockingMessage(usage) + }); + return; + } + + dialog_open({ + Title: cockpit.format(_("Permanently delete $0?"), pool.Name), + Teardown: TeardownMessage(usage), + Action: { + Danger: _("Deleting a Stratis pool will erase all data it contains."), + Title: _("Delete"), + action: function () { + return teardown_active_usage(client, usage) + .then(() => destroy_pool(pool)) + .then(() => { + location.go('/'); + }); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); +} + +function rename_pool(pool) { + dialog_open({ + Title: _("Rename Stratis pool"), + Fields: [ + TextInput("name", _("Name"), + { + value: pool.Name, + validate: name => validate_pool_name(pool, name) + }) + ], + Action: { + Title: _("Rename"), + action: function (vals) { + return pool.SetName(vals.name).then(std_reply); + } + } + }); +} + +function make_stratis_filesystem_pages(parent, pool) { + const filesystems = client.stratis_pool_filesystems[pool.path]; + const stats = client.stratis_pool_stats[pool.path]; + const forced_options = ["x-systemd.requires=stratis-fstab-setup@" + pool.Uuid + ".service"]; + const managed_fsys_sizes = client.features.stratis_managed_fsys_sizes && !pool.Overprovisioning; + + filesystems.forEach((fs, i) => make_stratis_filesystem_page(parent, pool, fs, + stats.fsys_offsets[i], + forced_options, + managed_fsys_sizes)); +} + +export function make_stratis_pool_page(parent, pool) { + const degraded_ops = pool.AvailableActions && pool.AvailableActions !== "fully_operational"; + const blockdevs = client.stratis_pool_blockdevs[pool.path] || []; + const can_grow = + (client.features.stratis_grow_blockdevs && + blockdevs.some(bd => bd.NewPhysicalSize[0] && Number(bd.NewPhysicalSize[1]) > Number(bd.TotalPhysicalSize))); + + const p = new_page({ + location: ["pool", pool.Uuid], + parent, + name: pool.Name, + columns: [ + pool.Encrypted ? _("Encrypted Stratis pool") : _("Stratis pool"), + "/dev/stratis/" + pool.Name + "/", + fmt_size(pool.TotalPhysicalSize), + ], + has_warning: degraded_ops || can_grow, + component: StratisPoolPage, + props: { pool, degraded_ops, can_grow }, + actions: [ + { title: _("Rename"), action: () => rename_pool(pool), }, + { title: _("Delete"), action: () => delete_pool(pool), danger: true }, + ], + }); + + make_stratis_filesystem_pages(p, pool); +} + +const StratisPoolPage = ({ page, pool, degraded_ops, can_grow }) => { + const key_desc = (pool.Encrypted && + pool.KeyDescription[0] && + pool.KeyDescription[1][1]); + const can_tang = (client.features.stratis_crypto_binding && + pool.Encrypted && + pool.ClevisInfo[0] && // pool has consistent clevis config + (!pool.ClevisInfo[1][0] || pool.ClevisInfo[1][1][0] == "tang")); // not bound or bound to "tang" + const tang_url = can_tang && pool.ClevisInfo[1][0] ? JSON.parse(pool.ClevisInfo[1][1][1]).url : null; + const blockdevs = client.stratis_pool_blockdevs[pool.path] || []; + const managed_fsys_sizes = client.features.stratis_managed_fsys_sizes && !pool.Overprovisioning; + const stats = client.stratis_pool_stats[pool.path]; + + function grow_blockdevs() { + return for_each_async(blockdevs, bd => pool.GrowPhysicalDevice(bd.Uuid)); + } + + const alerts = []; + if (can_grow) { + alerts.push( + + {_("Some block devices of this pool have grown in size after the pool was created. The pool can be safely grown to use the newly available space.")} +
+ + {_("Grow the pool to take all space")} + +
+
+
); + } + + if (degraded_ops) { + 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} + + }> + + + + + { !managed_fsys_sizes && use && + + + + } + { pool.Encrypted && client.features.stratis_crypto_binding && + + + { !key_desc + ? {_("Add passphrase")} + : <> + {_("Change")} + + + {_("Remove")} + + + + } + + + } + { can_tang && + + + { 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..66c634d60bce --- /dev/null +++ b/pkg/storaged/pages/stratis-stopped-pool.jsx @@ -0,0 +1,147 @@ +/* + * 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 { List, ListItem } from "@patternfly/react-core/dist/esm/components/List/index.js"; + +import { SCard } from "../utils/card.jsx"; +import { SDesc } from "../utils/desc.jsx"; +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 ( + + + }> + + + + { encrypted && client.features.stratis_crypto_binding && + + { key_desc ? cockpit.format(_("using key description $0"), key_desc) : _("none") } + + } + { can_tang && client.features.stratis_crypto_binding && + + } + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages/swap.jsx b/pkg/storaged/pages/swap.jsx new file mode 100644 index 000000000000..d4fde143c170 --- /dev/null +++ b/pkg/storaged/pages/swap.jsx @@ -0,0 +1,101 @@ +/* + * 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 { useEvent } from "hooks"; + +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, decode_filename } from "../utils.js"; +import { std_lock_action } from "../actions.jsx"; + +const _ = cockpit.gettext; + +export function make_swap_page(parent, backing_block, content_block, container) { + const block_swap = client.blocks_swap[content_block.path]; + + new_page({ + location: [block_location(backing_block)], + parent, + container, + name: block_name(backing_block), + columns: [ + _("Swap"), + null, + fmt_size(backing_block.Size) + ], + component: SwapPage, + props: { block: content_block, block_swap }, + actions: [ + std_lock_action(backing_block, content_block), + (block_swap && block_swap.Active + ? { title: _("Stop"), action: () => block_swap.Stop({}) } + : null), + (block_swap && !block_swap.Active + ? { title: _("Start"), action: () => block_swap.Start({}) } + : null), + { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + ] + }); +} + +export const SwapPage = ({ page, block, block_swap }) => { + const is_active = block_swap && block_swap.Active; + let used; + + useEvent(client.swap_sizes, "changed"); + + if (is_active) { + const samples = client.swap_sizes.data[decode_filename(block.Device)]; + if (samples) + used = fmt_size(samples[0] - samples[1]); + else + used = _("Unknown"); + } else { + used = "-"; + } + + return ( + + + }> + + + + + + + + + + + + ); +}; 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..79ad17098804 --- /dev/null +++ b/pkg/storaged/storage-page.jsx @@ -0,0 +1,66 @@ +/* + * 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 { get_page_from_location } from "./pages.jsx"; + +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; +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"; + +import { MultipathAlert } from "./multipath.jsx"; + +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/stratis-details.jsx b/pkg/storaged/stratis-details.jsx index 2fde5b647dc7..981c9e4690bd 100644 --- a/pkg/storaged/stratis-details.jsx +++ b/pkg/storaged/stratis-details.jsx @@ -72,7 +72,7 @@ export function check_stratis_warnings(client, enter_warning) { enter_warning(p, { warning: "not-fully-operational" }); } } - +g function teardown_block(block) { return for_each_async(block.Configuration, c => block.RemoveConfigurationItem(c, {})); } 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..71e721ec5886 100644 --- a/test/common/storagelib.py +++ b/test/common/storagelib.py @@ -122,10 +122,6 @@ def force_remove_disk(self, device): """ self.machine.execute(f'echo 1 > /sys/block/{os.path.basename(device)}/device/delete') - def devices_dropdown(self, title): - self.browser.click("#devices .pf-v5-c-dropdown button.pf-v5-c-dropdown__toggle") - self.browser.click(f"#devices .pf-v5-c-dropdown a:contains('{title}')") - # Content def content_row_tbody(self, index): @@ -496,16 +492,6 @@ def lvol_child_configuration_field(self, lvol, tab, field): def assert_in_lvol_child_configuration(self, lvol, tab, field, text): self.assertIn(text, self.lvol_child_configuration_field(lvol, tab, field)) - def wait_mounted(self, row, col): - with self.browser.wait_timeout(30): - self.content_tab_wait_in_info(row, col, "Mount point", - cond=lambda cell: "The filesystem is not mounted" not in self.browser.text(cell)) - - def wait_not_mounted(self, row, col): - with self.browser.wait_timeout(30): - self.content_tab_wait_in_info(row, col, "Mount point", - cond=lambda cell: "The filesystem is not mounted" in self.browser.text(cell)) - def setup_systemd_password_agent(self, password): # This sets up a systemd password agent that replies to all # queries with the given password. @@ -636,6 +622,51 @@ 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): + # XXX - make a data-test-button-title attribute? + return self.card(card_title) + f" button:contains('{button_title}')" + + def devices_dropdown(self, title): + self.browser.clicks(self.dropdown_action(self.card_header("Storage"), title)) + + def wait_mounted(self, card_title): + with self.browser.wait_timeout(30): + self.browser.wait_not_in_text(self.card_desc(card_title, "Mount point"), + "The filesystem is not mounted.") + + def wait_not_mounted(self, card_title): + with self.browser.wait_timeout(30): + self.browser.wait_in_text(self.card_desc(card_title, "Mount point"), + "The filesystem is not mounted.") + 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-iscsi b/test/verify/check-storage-iscsi index fd62724d9d7d..e9d8ba6384c8 100755 --- a/test/verify/check-storage-iscsi +++ b/test/verify/check-storage-iscsi @@ -68,7 +68,7 @@ class TestStorageISCSI(storagelib.StorageCase): # Set initiator IQN orig_iqn = m.execute("sed dt' - - def detail_row_value(index): - return f'#detail-header .pf-v5-c-description-list__group:nth-of-type({index}) > dd' - - def wait_detail_row(index, name): - b.wait_visible(detail_row_name(index)) - b.wait_text(detail_row_name(index), name) - def check_free_block_devices(expected): - blocks = b.eval_js("ph_texts('#dialog [data-field=\"disks\"] .checkbox')") - if len(blocks) != len(expected): - return False + allowed = ["/dev/vda4"] + blocks = list(filter(lambda b: b not in allowed, + b.eval_js("ph_texts('#dialog [data-field=\"disks\"] .select-space-details')"))) + self.assertEqual(len(blocks), len(expected)) for i in range(len(expected)): - if expected[i] not in blocks[i]: - return False - return True + self.assertIn(expected[i], blocks[i]) # At least on Fedora 27, multipath only looks at SCSI_IDENT_ # and ID_WWN properties, so we install a custom udev rule to @@ -63,13 +51,11 @@ class TestStorageMultipath(storagelib.StorageCase): }""") # Add a disk - m.add_disk("10M", serial="MYSERIAL") + info1 = m.add_disk("10M", serial="MYSERIAL") + dev1 = "/dev/" + info1["dev"] - b.wait_in_text("#drives", "MYSERIAL") - b.click('.sidepanel-row:contains("MYSERIAL")') - b.wait_visible('#storage-detail') - wait_detail_row(5, "Device file") - b.wait_in_text(detail_row_value(5), "/dev/sda") + b.click(self.card_row("Storage", location=dev1)) + b.wait_text(self.card_desc("Drive", "Device file"), dev1) # Add another disk with the same serial, which fools # multipathd into treating it as another path to the first @@ -77,33 +63,30 @@ class TestStorageMultipath(storagelib.StorageCase): # The primary device file should disappear and multipathed # devices should be listed. - m.add_disk("10M", serial="MYSERIAL") - b.wait_text_not(detail_row_value(5), "/dev/sda") - wait_detail_row(6, "Multipathed devices") - b.wait_in_text(detail_row_value(6), "/dev/sda") - b.wait_in_text(detail_row_value(6), "/dev/sdb") + info2 = m.add_disk("10M", serial="MYSERIAL") + dev2 = "/dev/" + info2["dev"] + + b.wait_text(self.card_desc("Drive", "Device file"), "-") + b.wait_in_text(self.card_desc("Drive", "Multipathed devices"), dev1) + b.wait_in_text(self.card_desc("Drive", "Multipathed devices"), dev2) # Check that neither is offered as a free block device b.go("#/") - self.devices_dropdown('Create LVM2 volume group') + b.clicks(self.dropdown_action(self.card_header("Storage"), "Create LVM2 volume group")) self.dialog_wait_open() check_free_block_devices([]) self.dialog_cancel() self.dialog_wait_close() - b.go("#/sda") - b.wait_visible('#storage-detail') - # Switch on multipathd. A primary device should appear. b.wait_visible('.pf-m-danger:contains(There are devices with multiple paths on the system, but)') b.click('button:contains(Start multipath)') - b.wait_in_text(detail_row_value(5), "/dev/mapper/mpatha") b.wait_not_present('.pf-m-danger:contains(There are devices with multiple paths on the system, but)') + b.wait_visible(self.card_row("Storage", location="/dev/mapper/mpatha")) # Check that (exactly) the primary device is offered as free - b.go("#/") - self.devices_dropdown('Create LVM2 volume group') + b.clicks(self.dropdown_action(self.card_header("Storage"), "Create LVM2 volume group")) self.dialog_wait_open() check_free_block_devices(["/dev/mapper/mpatha"]) self.dialog_cancel() diff --git a/test/verify/check-storage-nfs b/test/verify/check-storage-nfs index 9c44504eefec..4fcf2a96a0c4 100755 --- a/test/verify/check-storage-nfs +++ b/test/verify/check-storage-nfs @@ -29,6 +29,8 @@ class TestStorageNfs(storagelib.StorageCase): m = self.machine b = self.browser + m.upload(["/home/mvo/work/cockpit/dist/storaged"], "/usr/share/cockpit") + self.login_and_go("/storage") m.execute("mkdir /home/foo /home/bar /mnt/test") @@ -40,13 +42,10 @@ class TestStorageNfs(storagelib.StorageCase): # otherwise nfs is confused and we can't umount the share. self.addCleanup(m.execute, "if [ -e /mnt/test ]; then umount /mnt/test 2>/dev/null || true; rm -r /mnt/test; fi") - # Nothing there in the beginnging - b.wait_visible("#nfs-mounts .pf-v5-c-empty-state") - orig_fstab = m.execute("cat /etc/fstab") # Add /home/foo - b.click("#nfs-mounts button[aria-label='Add']") + b.clicks(self.dropdown_action(self.card_header("Storage"), "New NFS mount")) self.dialog_wait_open() self.dialog_set_val("server", "127.0.0.1") self.dialog_set_val("remote", "/home/foo") @@ -54,16 +53,16 @@ class TestStorageNfs(storagelib.StorageCase): self.dialog_apply() self.dialog_wait_close() - b.wait_visible("#nfs-mounts td:contains(/home/foo)") - b.wait_visible("#nfs-mounts td:contains(/mnt/test)") - b.wait_text_not("#nfs-mounts tr:contains(/mnt/test) .pf-v5-c-progress__status", "") + b.wait_visible(self.card_row("Storage", name="127.0.0.1:/home/foo")) + b.wait_visible(self.card_row("Storage", location="/mnt/test")) + b.wait_text_not(self.card_row("Storage", location="/mnt/test") + " .pf-v5-c-progress__status", "") # Should be saved to fstab self.assertEqual(m.execute("cat /etc/fstab"), orig_fstab + "127.0.0.1:/home/foo /mnt/test nfs defaults\n") # Try to add some non-exported directory - b.click("#nfs-mounts button[aria-label='Add']") + b.clicks(self.dropdown_action(self.card_header("Storage"), "New NFS mount")) self.dialog_wait_open() self.dialog_set_val("server", "127.0.0.1") b.set_input_text(self.dialog_field("remote") + " input", "/usr/share") @@ -75,7 +74,7 @@ class TestStorageNfs(storagelib.StorageCase): self.dialog_wait_close() # Add /home/bar - b.click("#nfs-mounts button[aria-label='Add']") + b.clicks(self.dropdown_action(self.card_header("Storage"), "New NFS mount")) self.dialog_wait_open() self.dialog_set_val("server", "127.0.0.1") self.dialog_set_val("remote", "/home/bar") @@ -83,32 +82,31 @@ class TestStorageNfs(storagelib.StorageCase): self.dialog_apply() self.dialog_wait_close() - b.wait_visible("#nfs-mounts td:contains(/home/bar)") - b.wait_visible("#nfs-mounts td:contains(/mounts/bar)") - b.wait_text_not("#nfs-mounts tr:contains(/mounts/bar) .pf-v5-c-progress__status", "") + b.wait_visible(self.card_row("Storage", name="127.0.0.1:/home/bar")) + b.wait_visible(self.card_row("Storage", location="/mounts/bar")) + b.wait_text_not(self.card_row("Storage", location="/mounts/bar") + " .pf-v5-c-progress__status", "") m.execute("test -d /mounts/bar") # Go to details of /home/bar - b.click("#nfs-mounts tr:contains(/home/bar)") - b.wait_visible('#storage-detail') - b.wait_text('#detail-header dt:contains("Server") + dd', "127.0.0.1:/home/bar") - b.wait_text('#detail-header dt:contains("Mount point") + dd', "/mounts/bar") + b.click(self.card_row("Storage", name="127.0.0.1:/home/bar")) + b.wait_text(self.card_desc("NFS mount", "Server"), "127.0.0.1:/home/bar") + b.wait_text(self.card_desc("NFS mount", "Mount point"), "/mounts/bar") # Change mount point of /home/bar - b.click('#detail-header button:contains("Edit")') + b.click(self.card_button("NFS mount", "Edit")) self.dialog_wait_open() self.dialog_set_val("dir", "/mounts/barbar") self.dialog_apply() self.dialog_wait_close() self.addCleanup(m.execute, "umount /mounts/barbar; rmdir /mounts/barbar") - b.wait_text('#detail-header dt:contains("Mount point") + dd', "/mounts/barbar") + b.wait_text(self.card_desc("NFS mount", "Mount point"), "/mounts/barbar") m.execute("! test -e /mounts/bar") m.execute("test -d /mounts/barbar") self.assertEqual(m.execute("findmnt -s -n -o OPTIONS /mounts/barbar").strip(), "defaults") # Set options for /home/bar - b.click('#detail-header button:contains("Edit")') + b.click(self.card_button("NFS mount", "Edit")) def wait_checked(field): b.wait_visible(self.dialog_field(field) + ":checked") @@ -134,22 +132,19 @@ class TestStorageNfs(storagelib.StorageCase): # Go to details of /home/foo b.go("#/") - b.wait_visible("#storage") - b.click("#nfs-mounts tr:contains(/home/foo)") - b.wait_visible('#storage-detail') - b.wait_text('#detail-header dt:contains("Server") + dd', "127.0.0.1:/home/foo") - b.wait_text('#detail-header dt:contains("Mount point") + dd', "/mnt/test") + b.click(self.card_row("Storage", name="127.0.0.1:/home/foo")) + b.wait_text(self.card_desc("NFS mount", "Server"), "127.0.0.1:/home/foo") + b.wait_text(self.card_desc("NFS mount", "Mount point"), "/mnt/test") # Unmount and remount /home/foo - b.click("#detail-header button:contains(Unmount)") - b.click("#detail-header button:contains(Mount)") - b.wait_visible("#detail-header button:contains(Unmount)") + b.click(self.card_button("NFS mount", "Unmount")) + b.click(self.card_button("NFS mount", "Mount")) + b.wait_visible(self.card_button("NFS mount", "Unmount")) # Remove /home/foo - b.click("#detail-header button:contains(Remove)") - b.wait_not_present('#storage-detail') - b.wait_visible("#storage") - b.wait_not_present("#nfs-mounts td:contains(/home/foo)") + b.click(self.card_button("NFS mount", "Remove")) + b.wait_visible(self.card("Storage")) + b.wait_not_present(self.card_row("Storage", name="127.0.0.1:/home/foo")) m.execute("! test -e /mnt/test") # Should be removed from fstab, too self.assertEqual(m.execute("cat /etc/fstab"), orig_fstab + @@ -157,17 +152,16 @@ class TestStorageNfs(storagelib.StorageCase): # Picks up mounts in fstab m.execute("echo '1.2.3.4:/something /somewhere nfs defaults 0 0' >> /etc/fstab") - b.wait_visible("#nfs-mounts td:contains(1.2.3.4 /something)") - b.wait_visible("#nfs-mounts td:contains(/somewhere)") - b.wait_visible("#nfs-mounts td:contains(Not mounted)") + b.wait_visible(self.card_row("Storage", name="1.2.3.4:/something")) + b.wait_visible(self.card_row("Storage", location="/somewhere (not mounted)")) # Ignores non-FS mounts which look similar m.execute("echo '2.3.4.5:/marmalade /dunno rfs defaults 0 0' >> /etc/fstab") # But recognizes variants like "nfs4" m.execute("echo '5.6.7.8:/stuff /four nfs4 defaults 0 0' >> /etc/fstab") - b.wait_visible("#nfs-mounts td:contains(5.6.7.8)") - b.wait_visible("#nfs-mounts td:contains(/four)") - self.assertFalse(b.is_present("#nfs-mounts td:contains(marmalade)")) + b.wait_visible(self.card_row("Storage", name="5.6.7.8:/stuff")) + b.wait_visible(self.card_row("Storage", location="/four (not mounted)")) + b.wait_not_present(self.card_row("Storage", name="2.3.4.5:/marmalade")) def testNfsListExports(self): m = self.machine @@ -180,7 +174,7 @@ class TestStorageNfs(storagelib.StorageCase): post_restore_action="systemctl restart nfs-server") m.execute("systemctl restart nfs-server") - b.click("#nfs-mounts [aria-label='Add']") + b.clicks(self.dropdown_action(self.card_header("Storage"), "New NFS mount")) self.dialog_wait_open() self.dialog_set_val("server", "127.0.0.1") @@ -212,7 +206,7 @@ class TestStorageNfs(storagelib.StorageCase): self.restore_file("/usr/sbin/showmount") m.execute("chmod a-x /usr/sbin/showmount") - b.click("#nfs-mounts [aria-label='Add']") + b.clicks(self.dropdown_action(self.card_header("Storage"), "New NFS mount")) self.dialog_wait_open() self.dialog_set_val("server", "127.0.0.1") # Manually add a remote location to the select @@ -223,9 +217,9 @@ class TestStorageNfs(storagelib.StorageCase): self.dialog_wait_close() self.addCleanup(m.execute, "umount /mnt") - b.wait_visible("#nfs-mounts td:contains(/home/foo)") - b.wait_visible("#nfs-mounts td:contains(/mnt)") - b.wait_text_not("#nfs-mounts tr:contains(/mnt) .pf-v5-c-progress__status", "") + b.wait_visible(self.card_row("Storage", name="127.0.0.1:/home/foo")) + b.wait_visible(self.card_row("Storage", location="/mnt")) + b.wait_text_not(self.card_row("Storage", location="/mnt") + " .pf-v5-c-progress__status", "") def testNfsBusy(self): m = self.machine @@ -239,11 +233,8 @@ class TestStorageNfs(storagelib.StorageCase): post_restore_action="systemctl restart nfs-server") m.execute("systemctl restart nfs-server") - # Nothing there in the beginnging - b.wait_visible("#nfs-mounts .pf-v5-c-empty-state") - # Add /home/foo - b.click("#nfs-mounts [aria-label='Add']") + b.clicks(self.dropdown_action(self.card_header("Storage"), "New NFS mount")) self.dialog_wait_open() self.dialog_set_val("server", "127.0.0.1") self.dialog_set_val("remote", "/home/foo") @@ -251,27 +242,24 @@ class TestStorageNfs(storagelib.StorageCase): self.dialog_apply() self.dialog_wait_close() - b.wait_visible("#nfs-mounts td:contains(/home/foo)") - b.wait_visible("#nfs-mounts td:contains(/mounts/foo)") - b.wait_text_not("#nfs-mounts tr:contains(/mounts/foo) .pf-v5-c-progress__status", "") + b.wait_visible(self.card_row("Storage", name="127.0.0.1:/home/foo")) + b.wait_visible(self.card_row("Storage", location="/mounts/foo")) + b.wait_text_not(self.card_row("Storage", location="/mounts/foo") + " .pf-v5-c-progress__status", "") # Go to details of /home/foo - b.go("#/") - b.wait_visible("#storage") - b.click("#nfs-mounts tr:contains(/home/foo)") - b.wait_visible('#storage-detail') - b.wait_text('#detail-header dt:contains("Server") + dd', "127.0.0.1:/home/foo") - b.wait_text('#detail-header dt:contains("Mount point") + dd', "/mounts/foo") + b.click(self.card_row("Storage", name="127.0.0.1:/home/foo")) + b.wait_text(self.card_desc("NFS mount", "Server"), "127.0.0.1:/home/foo") + b.wait_text(self.card_desc("NFS mount", "Mount point"), "/mounts/foo") sleep_pid = m.spawn("cd /mounts/foo; sleep infinity", "busy") - b.click('#detail-header button:contains("Edit")') + b.click(self.card_button("NFS mount", "Edit")) self.dialog_wait_open() self.dialog_wait_alert("This NFS mount is in use") self.dialog_cancel() self.dialog_wait_close() - b.click("#detail-header button:contains(Unmount)") + b.click(self.card_button("NFS mount", "Unmount")) self.dialog_wait_open() b.wait_in_text("#dialog", str(sleep_pid)) b.wait_in_text("#dialog", "sleep infinity") @@ -282,11 +270,11 @@ class TestStorageNfs(storagelib.StorageCase): self.dialog_apply() self.dialog_wait_close() - b.click("#detail-header button:contains(Mount)") - b.wait_visible("#detail-header button:contains(Unmount)") + b.click(self.card_button("NFS mount", "Mount")) + b.wait_visible(self.card_button("NFS mount", "Unmount")) sleep_pid = m.spawn("cd /mounts/foo; sleep infinity", "busy") - b.click("#detail-header button:contains(Remove)") + b.click(self.card_button("NFS mount", "Remove")) b.wait_in_text("#dialog", str(sleep_pid)) b.wait_in_text("#dialog", "sleep infinity") b.wait_in_text("#dialog", "The listed processes will be forcefully stopped.") @@ -295,7 +283,9 @@ class TestStorageNfs(storagelib.StorageCase): self.dialog_wait_close() # We are back on the Overview with nothing there - b.wait_visible("#nfs-mounts .pf-v5-c-empty-state") + b.wait_visible(self.card("Storage")) + b.wait_not_present(self.card_row("Storage", name="127.0.0.1:/home/foo")) + b.wait_not_present(self.card_row("Storage", location="/mounts/foo")) # Re-use allowed journal messages from StorageCase @@ -307,6 +297,9 @@ class TestStoragePackagesNFS(packagelib.PackageCase, storagelib.StorageCase, sto m = self.machine b = self.browser + m.execute("systemctl restart nfs-server") + m.upload(["/home/mvo/work/cockpit/dist/storaged"], "/usr/share/cockpit") + # Override configuration so that we don't have to remove the # real package. @@ -321,8 +314,7 @@ class TestStoragePackagesNFS(packagelib.PackageCase, storagelib.StorageCase, sto # The fake-nfs-utils package is not available yet - b.click("button:contains('Install NFS support')") - + b.clicks(self.dropdown_action(self.card_header("Storage"), "New NFS mount")) self.dialog_wait_open() b.wait_in_text("#dialog", "fake-nfs-utils is not available from any repository.") self.dialog_cancel() @@ -351,16 +343,21 @@ class TestStoragePackagesNFS(packagelib.PackageCase, storagelib.StorageCase, sto m.execute("systemctl restart packagekit") m.execute("pkcon refresh force; pkcon install -y dummy") - b.reload() - b.enter_page("/storage") - b.click("button:contains('Install NFS support')") + b.clicks(self.dropdown_action(self.card_header("Storage"), "New NFS mount")) self.dialog_wait_open() b.wait_in_text("#dialog", "fake-nfs-utils will be installed") b.wait_in_text("#dialog", "fake-libnfs") self.dialog_apply() + self.dialog_set_val("server", "127.0.0.1") + self.dialog_cancel() self.dialog_wait_close() - b.wait_visible("#nfs-mounts button[aria-label='Add']") + # Now we should go straight to the main dialog + b.clicks(self.dropdown_action(self.card_header("Storage"), "New NFS mount")) + self.dialog_wait_open() + self.dialog_set_val("server", "127.0.0.1") + self.dialog_cancel() + self.dialog_wait_close() if __name__ == '__main__': diff --git a/test/verify/check-storage-partitions b/test/verify/check-storage-partitions index c15bdb3a7fe8..46296cff3e0d 100755 --- a/test/verify/check-storage-partitions +++ b/test/verify/check-storage-partitions @@ -40,24 +40,20 @@ class TestStoragePartitions(storagelib.StorageCase): # https://github.com/storaged-project/storaged/issues/97 dev = self.add_loopback_disk(10, "loop12") + b.click(self.card_row("Storage", location=dev)) - b.wait_visible(f'.sidepanel-row:contains("{dev}")') - b.click(f'.sidepanel-row:contains("{dev}")') - b.wait_visible('#storage-detail') - - b.click('button:contains(Create partition table)') + b.click(self.card_button("Content", "Create partition table")) self.dialog({"type": "gpt"}) - self.content_row_wait_in_col(1, 0, "Free space") + b.wait_text(self.card_row_col("Partitions", 1, 1), "Free space") - self.content_row_action(1, "Create partition") + b.clicks(self.dropdown_action(self.card_row("Partitions", 1), "Create partition")) self.dialog({"type": "ext4", "mount_point": "/foo"}) - self.content_row_wait_in_col(1, 2, "ext4 filesystem") - self.wait_mounted(1, 2) + b.wait_text(self.card_row_col("Partitions", 1, 3), "/foo") - self.content_dropdown_action(1, "Delete") + b.clicks(self.dropdown_action(self.card_row("Partitions", 1), "Delete")) self.confirm() - self.content_row_wait_in_col(1, 0, "Free space") + b.wait_text(self.card_row_col("Partitions", 1, 1), "Free space") def testSizeSlider(self): m = self.machine @@ -66,14 +62,13 @@ class TestStoragePartitions(storagelib.StorageCase): self.login_and_go("/storage") disk = self.add_ram_disk() - b.click(f'#drives .sidepanel-row:contains("{disk}")') - b.wait_visible('#storage-detail') + b.click(self.card_row("Storage", location=disk)) - b.click('button:contains(Create partition table)') + b.click(self.card_button("Content", "Create partition table")) self.dialog({"type": "gpt"}) - self.content_row_wait_in_col(1, 0, "Free space") + b.wait_text(self.card_row_col("Partitions", 1, 1), "Free space") - self.content_row_action(1, "Create partition") + b.clicks(self.dropdown_action(self.card_row("Partitions", 1), "Create partition")) self.dialog_wait_open() self.dialog_set_val("type", "empty") @@ -119,59 +114,57 @@ class TestStoragePartitions(storagelib.StorageCase): self.login_and_go("/storage") disk = self.add_ram_disk(100) - b.click(f'#drives .sidepanel-row:contains("{disk}")') - b.wait_visible('#storage-detail') + b.click(self.card_row("Storage", location=disk)) - b.click('button:contains(Create partition table)') + b.click(self.card_button("Content", "Create partition table")) self.dialog({"type": "gpt"}) - self.content_row_wait_in_col(1, 0, "Free space") + b.wait_text(self.card_row_col("Partitions", 1, 1), "Free space") # Make two partitions that cover the whole disk. - self.content_row_action(1, "Create partition") + b.clicks(self.dropdown_action(self.card_row("Partitions", 1), "Create partition")) self.dialog({"type": "ext4", "mount_point": "/foo1", "size": 80 + nudge}, secondary=True) - self.content_row_action(2, "Create partition", isExpandable=False) + b.clicks(self.dropdown_action(self.card_row("Partitions", 2), "Create partition")) self.dialog({"type": "ext4", "mount_point": "/foo2", "size": 23}, secondary=True) - self.content_tab_wait_in_info(1, 1, "Size", "80.7 MB") - self.content_tab_wait_in_info(2, 1, "Size", "22.0 MB") - - # Both partitions can't be grown - self.wait_content_tab_action_disabled(1, 1, "Grow") - self.wait_content_tab_action_disabled(2, 1, "Grow") + b.wait_text(self.card_row_col("Partitions", 1, 4), "80.7 MB") + b.wait_text(self.card_row_col("Partitions", 2, 4), "22.0 MB") # Shrink the first - self.content_tab_action(1, 1, "Shrink") + b.click(self.card_row("Partitions", 1)) + b.click(self.card_button("Partition", "Shrink")) self.dialog({"size": 50}) - self.content_tab_wait_in_info(1, 1, "Size", "50.3 MB") + b.wait_in_text(self.card_desc("Partition", "Size"), "50.3 MB") # Grow it back externally, Cockpit should complain. Shrink it # again with Cockpit. m.execute(f"parted -s {disk} resizepart 1 81.7MB") - self.content_tab_action(1, 1, "Shrink partition") - self.content_tab_wait_in_info(1, 1, "Size", "50.3 MB") + b.click(self.card_button("Partition", "Shrink partition")) + b.wait_in_text(self.card_desc("Partition", "Size"), "50.3 MB") # Grow it back externally again. Grow the filesystem with # Cockpit. m.execute(f"parted -s {disk} resizepart 1 81.7MB") - self.content_tab_action(1, 1, "Grow content") - self.content_tab_wait_in_info(1, 1, "Size", "80.7 MB") - self.wait_content_tab_action_disabled(1, 1, "Grow") + b.click(self.card_button("Partition", "Grow content")) + b.wait_in_text(self.card_desc("Partition", "Size"), "80.7 MB") + b.wait_visible(self.card_button("Partition", "Grow") + ":disabled") # Delete second partition and grow the first to take all the # space. - self.content_dropdown_action(2, "Delete") + b.click(self.card_desc("ext4 filesystem", "Stored on") + " button") + b.clicks(self.dropdown_action(self.card_row("Partitions", 2), "Delete")) self.confirm() - self.content_tab_action(1, 1, "Grow") + b.click(self.card_row("Partitions", 1)) + b.click(self.card_button("Partition", "Grow")) self.dialog({"size": 103}) - self.wait_content_tab_action_disabled(1, 1, "Grow") - self.content_tab_wait_in_info(1, 1, "Size", "103 MB") + b.wait_visible(self.card_button("Partition", "Grow") + ":disabled") + b.wait_in_text(self.card_desc("Partition", "Size"), "103 MB") if __name__ == '__main__': diff --git a/test/verify/check-storage-raid1 b/test/verify/check-storage-raid1 index 387bbb93560b..c0c62869532a 100755 --- a/test/verify/check-storage-raid1 +++ b/test/verify/check-storage-raid1 @@ -33,11 +33,11 @@ class TestStorageRaid1(storagelib.StorageCase): # Create two disks and make a RAID out of them disk1 = self.add_loopback_disk() disk2 = self.add_loopback_disk() - b.wait_in_text("#others", 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)) self.addCleanup(m.execute, "mdadm --manage --stop /dev/md/SOMERAID") - self.devices_dropdown('Create RAID device') + b.clicks(self.dropdown_action(self.card_header("Storage"), "Create RAID device")) self.dialog_wait_open() # No swap block devices should show up b.wait_not_in_text("#dialog .pf-v5-c-data-list", "zram") @@ -47,7 +47,7 @@ class TestStorageRaid1(storagelib.StorageCase): # The dialog should make sure that the Chunk size is ignored (has to be 0 for RAID 1) self.dialog_apply() self.dialog_wait_close() - b.wait_in_text("#devices", "SOMERAID") + b.wait_visible(self.card_row("Storage", name="SOMERAID")) if __name__ == '__main__': diff --git a/test/verify/check-storage-stratis b/test/verify/check-storage-stratis index e7ecb025840f..9f52263888d4 100755 --- a/test/verify/check-storage-stratis +++ b/test/verify/check-storage-stratis @@ -56,11 +56,11 @@ class TestStorageStratis(storagelib.StorageCase): dev_3 = self.add_loopback_disk(PV_SIZE, name="loop12") dev_4 = self.add_loopback_disk(PV_SIZE, name="loop13") dev_5 = self.add_loopback_disk(PV_SIZE, name="loop14") - b.wait_in_text("#others", dev_1) - b.wait_in_text("#others", dev_2) - b.wait_in_text("#others", dev_3) - b.wait_in_text("#others", dev_4) - b.wait_in_text("#others", dev_5) + b.wait_visible(self.card_row("Storage", location=dev_1)) + b.wait_visible(self.card_row("Storage", location=dev_2)) + b.wait_visible(self.card_row("Storage", location=dev_3)) + b.wait_visible(self.card_row("Storage", location=dev_4)) + b.wait_visible(self.card_row("Storage", location=dev_5)) # Create a pool self.dialog_open_with_retry(trigger=lambda: self.devices_dropdown("Create Stratis pool"), @@ -72,10 +72,8 @@ class TestStorageStratis(storagelib.StorageCase): self.dialog_apply() self.dialog_wait_close() - b.wait_in_text("#devices", "pool0") - b.wait_in_text("#devices", "8 GB Stratis pool") - b.assert_pixels("#devices", "pool-row") - b.wait_not_present('#devices .ct-icon-exclamation-triangle') + b.wait_visible(self.card_row("Storage", name="pool0")) + b.wait_not_present(self.card_row("Storage", name="pool0") + " .ct-icon-exclamation-triangle") # Check that the next name is "pool1" self.devices_dropdown("Create Stratis pool") @@ -88,21 +86,21 @@ class TestStorageStratis(storagelib.StorageCase): # Stop the pool (only works with Stratis 3) pool_uuid = m.execute("stratis --unhyphenated-uuids pool list --name pool0 | grep ^UUID | cut -d' ' -f2").strip() m.execute("stratis pool stop pool0") - b.wait_in_text(f'.sidepanel-row:contains("{pool_uuid}")', "Stopped Stratis pool") + b.wait_in_text(self.card_row("Storage", name=pool_uuid), "Stopped Stratis pool") # Start it - b.click(f'.sidepanel-row:contains("{pool_uuid}") button') - b.wait_in_text("#devices", "pool0") - b.wait_in_text("#devices", "8 GB Stratis pool") + b.clicks(self.dropdown_action(self.card_row("Storage", name=pool_uuid), "Start")) + b.wait_visible(self.card_row("Storage", name="pool0")) - b.click('.sidepanel-row:contains("pool0")') - b.wait_visible('#storage-detail') + b.click(self.card_row("Storage", name="pool0")) + b.wait_text(self.card_desc("Stratis pool", "Name"), "pool0") + b.wait_in_text(self.card_desc("Stratis pool", "Usage"), "8 GB") b.wait_not_present('.pf-v5-c-alert') udisk_contains_stratis_private = "physical-originsub" in m.execute("udisksctl dump") # Create two filesystems - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog_wait_open() self.dialog_set_val('name', 'fsys1') self.dialog_set_val('mount_point', '/run/fsys1') @@ -111,18 +109,19 @@ class TestStorageStratis(storagelib.StorageCase): self.dialog_wait_close() self.addCleanup(m.execute, "umount /run/fsys1 || true") - b.wait_in_text("#detail-content", "fsys1") - b.assert_pixels("#detail-content", "fsys-row") + b.wait_text(self.card_row_col("Filesystems", 1, 1), "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys1") self.assertEqual(self.inode(m.execute("findmnt -n -o SOURCE /run/fsys1").strip()), self.inode("/dev/stratis/pool0/fsys1")) - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog({'name': 'fsys2', 'mount_point': '/run/fsys2'}) self.addCleanup(m.execute, "umount /run/fsys2 || true") - b.wait_in_text("#detail-content", "fsys2") - b.assert_pixels("#detail-content", "fsys-rows") + b.wait_text(self.card_row_col("Filesystems", 2, 1), "fsys2") + b.wait_text(self.card_row_col("Filesystems", 2, 3), "/run/fsys2") + b.assert_pixels(self.card("Filesystems"), "fsys-rows") self.assertEqual(self.inode(m.execute("findmnt -n -o SOURCE /run/fsys2").strip()), self.inode("/dev/stratis/pool0/fsys2")) m.write("/run/fsys2/FILE", "Hello Stratis!") @@ -132,29 +131,28 @@ class TestStorageStratis(storagelib.StorageCase): self.assertNotEqual(m.execute("grep /run/fsys2 /etc/fstab"), "") # Rename one filesystem - self.content_dropdown_action(1, "Rename") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Rename")) self.dialog({'name': "fsys1-renamed"}) - b.wait_in_text("#detail-content", "fsys1-renamed") + b.wait_text(self.card_row_col("Filesystems", 1, 1), "fsys1-renamed") # Destroy one filesystem - self.wait_mounted(1, 1) - self.content_dropdown_action(1, "Delete") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Delete")) self.dialog_wait_open() b.assert_pixels("#dialog", "delete-fsys") self.dialog_apply() self.dialog_wait_close() - b.wait_not_in_text("#detail-content", "fsys1-renamed") + b.wait_not_present(self.card_row("Filesystems", name="fsys1-renamed")) # Unmount and remount the other filesystem - self.content_dropdown_action(1, "Unmount") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Unmount")) self.confirm() - self.content_tab_wait_in_info(1, 1, "Mount point", "The filesystem is not mounted") - self.content_row_action(1, "Mount") + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys2 (not mounted)") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Mount")) self.dialog({}) - self.wait_mounted(1, 1) + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys2") # Make a copy of the filesystem - self.content_dropdown_action(1, "Snapshot") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Snapshot")) self.dialog_wait_open() self.dialog_set_val('name', 'fsys2-copy') self.dialog_set_val('mount_point', '/run/fsys2-copy') @@ -162,95 +160,93 @@ class TestStorageStratis(storagelib.StorageCase): b.assert_pixels("#dialog", "copy-fsys") self.dialog_apply() self.dialog_wait_close() - b.wait_in_text("#detail-content", "fsys2-copy") + b.wait_text(self.card_row_col("Filesystems", 2, 1), "fsys2-copy") + b.wait_text(self.card_row_col("Filesystems", 2, 3), "/run/fsys2-copy") self.assertEqual("Hello Stratis!", m.execute("cat /run/fsys2-copy/FILE")) # Delete the copy - self.wait_mounted(2, 1) - self.content_dropdown_action(2, "Delete") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 2), "Delete")) self.confirm() - b.wait_not_in_text("#detail-content", "fsys2-copy") + b.wait_not_present(self.card_row("Filesystems", name="fsys2-copy")) # Make an unmounted copy of the filesystem - self.content_dropdown_action(1, "Snapshot") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Snapshot")) self.dialog_wait_open() self.dialog_set_val('name', 'fsys2-copy') self.dialog_set_val('at_boot', 'never') self.dialog_apply_secondary() self.dialog_wait_close() - b.wait_in_text("#detail-content", "fsys2-copy") + b.wait_text(self.card_row_col("Filesystems", 2, 1), "fsys2-copy") + b.wait_text(self.card_row_col("Filesystems", 2, 3), "(not mounted)") # Delete the copy - self.content_dropdown_action(2, "Delete") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 2), "Delete")) self.confirm() - b.wait_not_in_text("#detail-content", "fsys2-copy") + b.wait_not_present(self.card_row("Filesystems", name="fsys2-copy")) # Create an unmounted filesystem - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog_wait_open() self.dialog_set_val('name', 'fsys-unmounted') self.dialog_apply_secondary() self.dialog_wait_close() - b.wait_in_text("#detail-content", "fsys-unmounted") + b.wait_text(self.card_row_col("Filesystems", 2, 1), "fsys-unmounted") + b.wait_text(self.card_row_col("Filesystems", 2, 3), "(not mounted)") # Delete the unmounted filesystem - self.content_dropdown_action(2, "Delete") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 2), "Delete")) self.confirm() - b.wait_not_in_text("#detail-content", "fsys2-copy") + b.wait_not_present(self.card_row("Filesystems", name="fsys-unmounted")) # Add a data blockdev - b.click('#detail-sidebar .pf-v5-c-card__actions button') + b.click(self.card_button("Block devices", "Add block device")) self.dialog_wait_open() self.dialog_apply() self.dialog_wait_error("disks", "At least one") self.dialog_set_val('disks', {dev_3: True}) - # FIXME: Remove ignore when fixed: https://bugzilla.redhat.com/show_bug.cgi?id=2183084 - # b.assert_pixels("#dialog", "add-disk", ignore=[".pf-v5-c-data-list__item-content:contains(stratis)"]) + b.assert_pixels("#dialog", "add-disk") self.dialog_apply() self.dialog_wait_close() - b.wait_in_text('#detail-sidebar', dev_3) - b.wait_in_text(f'#detail-sidebar .sidepanel-row:contains({dev_3})', "data") + b.wait_visible(self.card_row("Block devices", name=dev_3)) + b.wait_in_text(self.card_desc("Stratis pool", "Usage"), "12 GB") # Add a cache blockdev - b.click('#detail-sidebar .pf-v5-c-card__actions button') + b.click(self.card_button("Block devices", "Add block device")) self.dialog({'tier': "cache", 'disks': {dev_4: True}}) - b.wait_in_text('#detail-sidebar', dev_4) - b.wait_in_text(f'#detail-sidebar .sidepanel-row:contains({dev_4})', "cache") + b.wait_in_text(self.card_row("Block devices", name=dev_4), "cache") # Add a second cache blockdev, this uses a different code path - b.click('#detail-sidebar .pf-v5-c-card__actions button') + b.click(self.card_button("Block devices", "Add block device")) self.dialog({'tier': "cache", 'disks': {dev_5: True}}) - b.wait_in_text('#detail-sidebar', dev_5) - b.wait_in_text(f'#detail-sidebar .sidepanel-row:contains({dev_5})', "cache") + b.wait_in_text(self.card_row("Block devices", name=dev_5), "cache") # Rename the pool - b.click('#detail-header button:contains(Rename)') + b.click(self.card_button("Stratis pool", "Rename")) self.dialog({'name': "pool0-renamed"}) - b.wait_in_text('#detail-header', "pool0-renamed") + b.wait_text(self.card_desc("Stratis pool", "Name"), "pool0-renamed") # Create another filesystem - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog({'name': 'fsys3', 'mount_point': '/run/fsys3'}) - b.wait_in_text("#detail-content", "fsys3") + b.wait_text(self.card_row_col("Filesystems", 2, 1), "fsys3") + b.wait_text(self.card_row_col("Filesystems", 2, 3), "/run/fsys3") self.assertEqual(self.inode(m.execute("findmnt -n -o SOURCE /run/fsys3").strip()), self.inode("/dev/stratis/pool0-renamed/fsys3")) # Destroy the pool - self.wait_mounted(1, 1) - self.wait_mounted(2, 1) - b.click('#detail-header button:contains(Delete)') + b.click(self.card_button("Stratis pool", "Delete")) self.dialog_wait_open() b.assert_pixels('#dialog', "delete-pool") self.dialog_apply() self.dialog_wait_close() - b.wait_visible("#storage") - b.wait_not_in_text("#devices", "pool0-renamed") + b.wait_visible(self.card("Storage")) + b.wait_not_present(self.card_row("Storage", name="pool0-renamed")) # Check that the entries have disappeared from fstab self.assertEqual(m.execute("grep /run/fsys1 /etc/fstab || true"), "") @@ -276,8 +272,8 @@ class TestStorageStratis(storagelib.StorageCase): dev_1 = self.add_loopback_disk(PV_SIZE) dev_2 = self.add_loopback_disk(PV_SIZE) - b.wait_in_text("#others", 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 an encrypted pool with two block devices self.devices_dropdown("Create Stratis pool") @@ -290,29 +286,30 @@ class TestStorageStratis(storagelib.StorageCase): self.dialog_apply() self.dialog_wait_close() - b.wait_in_text("#devices", "pool0") - b.wait_not_present('.sidepanel-row:contains(pool0) .ct-icon-exclamation-triangle') + b.wait_visible(self.card_row("Storage", name="pool0")) + b.wait_not_present(self.card_row("Storage", name="pool0") + " .ct-icon-exclamation-triangle") + # Check that there is no alert on the details page - b.click('.sidepanel-row:contains("pool0")') - b.wait_in_text('#detail-header', "Encrypted Stratis pool pool0") + b.click(self.card_row("Storage", name="pool0")) + b.wait_visible(self.card("Encrypted Stratis pool")) b.wait_not_present('.pf-v5-c-alert') - m.execute(f""" -JSON=$(sudo cryptsetup token export --token-id=1 {dev_1} \ + m.execute(""" +JSON=$(sudo cryptsetup token export --token-id=1 /dev/sda \ | jq '.key_description = "stratis-1-key-no-other-is-the-same"') -sudo cryptsetup token remove --token-id=1 {dev_1} -echo $JSON | sudo cryptsetup token import --token-id=1 {dev_1} +sudo cryptsetup token remove --token-id=1 /dev/sda +echo $JSON | sudo cryptsetup token import --token-id=1 /dev/sda systemctl restart stratisd """) b.go('#/') - b.wait_visible('.sidepanel-row:contains(pool0) .ct-icon-exclamation-triangle') + b.wait_visible(self.card_row("Storage", name="pool0") + " .ct-icon-exclamation-triangle") - b.click('.sidepanel-row:contains("pool0")') + b.click(self.card_row("Storage", name="pool0")) b.wait_visible('.pf-v5-c-alert:contains("This pool is in a degraded state")') - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog_wait_open() self.dialog_set_val("name", "fsys1") self.dialog_set_val("mount_point", "/run/fsys1") @@ -328,50 +325,53 @@ systemctl restart stratisd dev_1 = self.add_loopback_disk(PV_SIZE) dev_2 = self.add_loopback_disk(PV_SIZE) + b.wait_visible(self.card_row("Storage", location=dev_1)) + b.wait_visible(self.card_row("Storage", location=dev_2)) # Create a pool outside of Cockpit m.execute(f"stratis pool create TEST1 {dev_1} {dev_2}") - b.wait_in_text("#devices", "TEST1") - b.wait_in_text("#devices", "/dev/stratis/TEST1/") - b.click('.sidepanel-row:contains("TEST1")') - b.wait_visible("#storage-detail") - b.wait_in_text("#detail-sidebar", dev_1) - b.wait_in_text("#detail-sidebar", dev_2) + b.wait_visible(self.card_row("Storage", name="TEST1")) + b.wait_visible(self.card_row("Storage", location="/dev/stratis/TEST1/")) + b.wait_visible(self.card_row("Storage", name="TEST1")) + b.wait_in_text(self.card_row("Storage", name=dev_1), "Stratis block device") + b.wait_in_text(self.card_row("Storage", name=dev_1), "TEST1") + b.wait_in_text(self.card_row("Storage", name=dev_2), "Stratis block device") + b.wait_in_text(self.card_row("Storage", name=dev_1), "TEST1") # Create two filesystems outside of Cockpit m.execute("stratis filesystem create TEST1 fsys1") - b.wait_in_text("#detail-content", "fsys1") + b.wait_visible(self.card_row("Storage", name="fsys1")) m.execute("stratis filesystem create TEST1 fsys2") - b.wait_in_text("#detail-content", "fsys2") - - mount = f"{self.vm_tmpdir}/fsys1" + b.wait_visible(self.card_row("Storage", name="fsys2")) # Mount externally, adjust fstab with Cockpit - m.execute(f"mkdir {mount}; mount /dev/stratis/TEST1/fsys1 {mount}") - fsys_tab = self.content_tab_expand(1, 1) - b.click(fsys_tab + f" button:contains(Mount automatically on {mount} on boot)") - b.wait_not_present(fsys_tab + f" button:contains(Mount automatically on {mount} on boot)") - self.assertIn("stratis-fstab-setup", m.execute(f"grep {mount} /etc/fstab")) + b.click(self.card_row("Storage", name="fsys1")) + m.execute("mkdir /run/fsys1; mount /dev/stratis/TEST1/fsys1 /run/fsys1") + b.click(self.card_button("Stratis filesystem", "Mount automatically on /run/fsys1 on boot")) + b.wait_not_present(self.card_button("Stratis filesystem", "Mount automatically on /run/fsys1 on boot")) + self.assertIn("stratis-fstab-setup", m.execute("grep /run/fsys1 /etc/fstab")) # Unmount externally, adjust fstab with Cockpit - m.execute(f"umount {mount}") - b.click(fsys_tab + " button:contains(Do not mount automatically on boot)") - b.wait_not_present(fsys_tab + " button:contains(Do not mount automatically on boot)") - self.assertIn("noauto", m.execute(f"grep {mount} /etc/fstab")) + m.execute("umount /run/fsys1") + b.click(self.card_button("Stratis filesystem", "Do not mount automatically on boot")) + b.wait_not_present(self.card_button("Stratis filesystem", "Do not mount automatically on boot")) + self.assertIn("noauto", m.execute("grep /run/fsys1 /etc/fstab")) # Destroy them outside of Cockpit + b.click(self.card_desc("Stratis filesystem", "Stored on") + " button") + b.wait_visible(self.card("Filesystems")) m.execute("stratis filesystem destroy TEST1 fsys1") - b.wait_not_in_text("#detail-content", "fsys1") + b.wait_not_present(self.card_row("Filesystems", name="fsys1")) m.execute("stratis filesystem destroy TEST1 fsys2") - b.wait_not_in_text("#detail-content", "fsys2") + b.wait_not_present(self.card_row("Filesystems", name="fsys2")) # Destroy the pool outside of Cockpit m.execute("stratis pool destroy TEST1") - b.wait_in_text("#storage-detail", "Not found") + b.wait_in_text("main", "Not found") b.go("#/") - b.wait_visible('#storage') - b.wait_not_in_text("#devices", "TEST1") + b.wait_visible(self.card("Storage")) + b.wait_not_present(self.card_row("Storage", name="TEST1")) @testlib.skipImage("No Stratis", "debian-*", "ubuntu-*") @@ -400,15 +400,15 @@ class TestStorageStratisReboot(storagelib.StorageCase): dev_1 = "/dev/sda" m.add_disk("4G", serial="DISK1") - b.wait_in_text("#drives", dev_1) + b.wait_visible(self.card_row("Storage", location=dev_1)) dev_2 = "/dev/sdb" m.add_disk("4G", serial="DISK2") - b.wait_in_text("#drives", dev_2) + b.wait_visible(self.card_row("Storage", location=dev_2)) dev_3 = "/dev/sdc" m.add_disk("4G", serial="DISK3") - b.wait_in_text("#drives", dev_3) + b.wait_visible(self.card_row("Storage", location=dev_3)) passphrase = "foodeeboodeebar" @@ -431,23 +431,22 @@ class TestStorageStratisReboot(storagelib.StorageCase): self.dialog_wait_close() m.execute("stratis key unset pool0") - b.wait_in_text("#devices", "pool0") - b.click('.sidepanel-row:contains("pool0")') - b.wait_visible('#storage-detail') - b.wait_in_text('#detail-header', "Encrypted Stratis pool pool0") + b.click(self.card_row("Storage", name="pool0")) + b.wait_visible(self.card("Encrypted Stratis pool")) - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog({'name': 'fsys1', 'mount_point': '/run/fsys1', 'at_boot': 'local'}, secondary=True) - b.wait_in_text("#detail-content", "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 1), "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys1 (not mounted)") # Check that it has an entry in fstab and that it is "noauto" self.assertIn("noauto", m.execute("grep /run/fsys1 /etc/fstab")) # Add a data blockdev - b.click('#detail-sidebar .pf-v5-c-card__actions button') + b.click(self.card_button("Block devices", "Add block device")) self.dialog_wait_open() self.dialog_set_val('disks', {dev_2: True}) self.dialog_apply() @@ -455,47 +454,44 @@ class TestStorageStratisReboot(storagelib.StorageCase): self.dialog_set_val('passphrase', passphrase) self.dialog_apply() self.dialog_wait_close() - b.wait_in_text('#detail-sidebar', dev_2) - b.wait_in_text(f'#detail-sidebar .sidepanel-row:contains({dev_2})', "data") + b.wait_in_text(self.card_row("Block devices", name=dev_2), "data") # Change the passphrase (if supported) if not self.stratis_v2: - b.wait_visible('#detail-header .pf-v5-c-description-list__group:contains(Passphrase) button:contains(Remove)[aria-disabled=true]') - b.click('#detail-header .pf-v5-c-description-list__group:contains(Passphrase) button:contains(Change)') + b.wait_visible(self.card_desc("Encrypted Stratis pool", "Passphrase") + " button:contains(Remove):disabled") + b.click(self.card_desc("Encrypted Stratis pool", "Passphrase") + " button:contains(Change)") self.dialog({'old_passphrase': passphrase, 'new_passphrase': "boodeefoodeebar", 'new_passphrase2': "boodeefoodeebar"}) # do it again, with the old passphrase in the keyring m.execute("echo boodeefoodeebar | stratis key set pool0 --capture-key") - b.click('#detail-header .pf-v5-c-description-list__group:contains(Passphrase) button:contains(Change)') + b.click(self.card_desc("Encrypted Stratis pool", "Passphrase") + " button:contains(Change)") self.dialog({'new_passphrase': passphrase, 'new_passphrase2': passphrase}) m.execute("stratis key unset pool0") # Add a cache blockdev (if supported) if not self.stratis_v2: - b.click('#detail-sidebar .pf-v5-c-card__actions button') + b.click(self.card_button("Block devices", "Add block device")) self.dialog_wait_open() self.dialog_set_val('tier', "cache") self.dialog_set_val('disks', {dev_3: True}) self.dialog_set_val('passphrase', passphrase) self.dialog_apply() self.dialog_wait_close() - b.wait_in_text('#detail-sidebar', dev_3) - b.wait_in_text(f'#detail-sidebar .sidepanel-row:contains({dev_3})', "cache") + b.wait_in_text(self.card_row("Block devices", name=dev_3), "cache") m.reboot() m.start_cockpit() b.relogin() b.enter_page("/storage") - b.wait_visible("#storage-detail") - b.wait_in_text('#detail-header', "Stopped Stratis pool") - b.wait_in_text('#detail-sidebar', "DISK1") - b.wait_in_text('#detail-sidebar', "DISK2") + b.wait_visible(self.card("Stopped Stratis pool")) + b.wait_in_text(self.card("Block devices"), "DISK1") + b.wait_in_text(self.card("Block devices"), "DISK2") # Unlock the pool - b.click('#detail-header button:contains(Start)') + b.click(self.card_button("Stopped Stratis pool", "Start")) self.dialog_wait_open() self.dialog_set_val('passphrase', "wrong-passphrase") self.dialog_apply() @@ -504,13 +500,13 @@ class TestStorageStratisReboot(storagelib.StorageCase): self.dialog_set_val('passphrase', passphrase) self.dialog_apply() self.dialog_wait_close() - b.wait_not_in_text('#detail-header', "Stopped") - b.wait_in_text('#detail-header', "Encrypted Stratis pool pool0") + b.wait_text(self.card_desc("Encrypted Stratis pool", "Name"), "pool0") # Mount the filesystem - self.content_row_action(1, "Mount") + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys1 (not mounted)") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Mount")) self.dialog({}) - self.wait_mounted(1, 1) + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys1") # Reboot (this requires the passphrase) self.setup_systemd_password_agent(passphrase) @@ -518,16 +514,16 @@ class TestStorageStratisReboot(storagelib.StorageCase): m.start_cockpit() b.relogin() b.enter_page("/storage") - b.wait_visible("#storage-detail") + b.wait_text(self.card_desc("Encrypted Stratis pool", "Name"), "pool0") # Filesystem should be mounted now - self.wait_mounted(1, 1) + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys1") # Destroy the pool - b.click('#detail-header button:contains(Delete)') + b.click(self.card_button("Encrypted Stratis pool", "Delete")) self.confirm() - b.wait_visible("#storage") - b.wait_not_in_text("#devices", "pool0") + b.wait_visible(self.card("Storage")) + b.wait_not_present(self.card_row("Storage", name="pool0")) # Check that the entry has disappeared from fstab self.assertEqual(m.execute("grep /run/fsys1 /etc/fstab || true"), "") @@ -540,32 +536,30 @@ class TestStorageStratisReboot(storagelib.StorageCase): dev_1 = "/dev/sda" m.add_disk("4G", serial="DISK1") - b.wait_in_text("#drives", dev_1) + b.wait_visible(self.card_row("Storage", location=dev_1)) # Create a pool self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create Stratis pool"), expect=lambda: (self.dialog_is_present('disks', dev_1) and self.dialog_check({"name": "pool0"})), values={"disks": {dev_1: True}}) - b.wait_in_text("#devices", "pool0") - - b.click('.sidepanel-row:contains("pool0")') - b.wait_visible('#storage-detail') + b.click(self.card_row("Storage", name="pool0")) # Create a filesystems - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog({'name': 'fsys1', 'mount_point': '/run/fsys1'}) - b.wait_in_text("#detail-content", "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 1), "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys1") m.reboot() m.start_cockpit() b.relogin() b.enter_page("/storage") - b.wait_visible("#storage-detail") # Filesystem should be mounted now - self.wait_mounted(1, 1) + b.wait_text(self.card_row_col("Filesystems", 1, 1), "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys1") def testAtBoot(self): m = self.machine @@ -575,31 +569,28 @@ class TestStorageStratisReboot(storagelib.StorageCase): dev_1 = "/dev/sda" m.add_disk("4G", serial="DISK1") - b.wait_in_text("#drives", dev_1) + b.wait_visible(self.card_row("Storage", location=dev_1)) # Create a pool self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create Stratis pool"), expect=lambda: (self.dialog_is_present('disks', dev_1) and self.dialog_check({"name": "pool0"})), values={"disks": {dev_1: True}}) - b.wait_in_text("#devices", "pool0") - - b.click('.sidepanel-row:contains("pool0")') - b.wait_visible('#storage-detail') + b.click(self.card_row("Storage", name="pool0")) def create(at_boot): - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog({'name': 'fsys1', 'mount_point': '/foo', 'at_boot': at_boot}) - b.wait_in_text("#detail-content", "fsys1") - self.wait_mounted(1, 1) + b.wait_text(self.card_row_col("Filesystems", 1, 1), "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/foo") def destroy(): - self.content_dropdown_action(1, "Delete") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Delete")) self.dialog_wait_open() self.dialog_apply_with_retry("Device or resource busy") - b.wait_not_in_text("#detail-content", "fsys1") + b.wait_not_present(self.card_row("Filesystems", name="fsys1")) create("local") self.assertNotIn("noauto", m.execute("findmnt --fstab -n -o OPTIONS /foo")) @@ -627,60 +618,57 @@ class TestStorageStratisReboot(storagelib.StorageCase): dev_1 = "/dev/sda" m.add_disk("4G", serial="DISK1") - b.wait_in_text("#drives", dev_1) + b.wait_visible(self.card_row("Storage", location=dev_1)) # Create a "managed" pool self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create Stratis pool"), expect=lambda: (self.dialog_is_present('disks', dev_1) and self.dialog_check({"name": "pool0"})), values={"managed.on": True, "disks": {dev_1: True}}) - b.wait_in_text("#devices", "pool0") - - b.click('.sidepanel-row:contains("pool0")') - b.wait_visible('#storage-detail') + b.click(self.card_row("Storage", name="pool0")) # Create a small filesystem - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog({'name': 'fsys1', 'size': 900, 'mount_point': '/run/fsys1'}) - b.wait_in_text("#detail-content", "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 1), "fsys1") # Make a snapshot of it - self.content_dropdown_action(1, "Snapshot") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Snapshot")) self.dialog({'name': 'fsys1-copy', 'mount_point': '/run/fsys1-copy'}) - b.wait_in_text("#detail-content", "fsys1-copy") + b.wait_text(self.card_row_col("Filesystems", 2, 1), "fsys1-copy") # And another filesystem - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog({'name': 'fsys2', 'size': 800, 'mount_point': '/run/fsys2'}) - b.wait_in_text("#detail-content", "fsys2") + b.wait_text(self.card_row_col("Filesystems", 3, 1), "fsys2") # And fill the rest by accepting the default size - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog({'name': 'fsys3', 'mount_point': '/run/fsys3'}) - b.wait_in_text("#detail-content", "fsys3") - b.wait_visible("button:contains(Create new filesystem):disabled") + b.wait_text(self.card_row_col("Filesystems", 4, 1), "fsys3") + b.wait_visible(self.card_button("Filesystems", "Create new filesystem") + ":disabled") # Snapshots are impossible now - self.content_dropdown_action(2, "Snapshot") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Snapshot")) self.dialog_wait_open() b.wait_in_text('#dialog', "Not enough space") self.dialog_cancel() self.dialog_wait_close() # Delete a filesystem, and make another snapshot - self.content_dropdown_action(1, "Delete") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 1), "Delete")) self.confirm() - b.wait_visible("button:contains(Create new filesystem):not(:disabled)") - self.content_dropdown_action(2, "Snapshot") + b.wait_visible(self.card_button("Filesystems", "Create new filesystem") + ":not(:disabled)") + b.clicks(self.dropdown_action(self.card_row("Filesystems", 2), "Snapshot")) self.dialog({'name': 'fsys2-copy', 'mount_point': '/run/fsys2-copy'}) - b.wait_in_text("#detail-content", "fsys2-copy") + b.wait_visible(self.card_row("Filesystems", name="fsys2-copy")) # And the pool should be full again b.wait_visible("button:contains(Create new filesystem):disabled") @@ -694,60 +682,50 @@ class TestStorageStratisReboot(storagelib.StorageCase): dev = "/dev/sda" m.add_disk("4G", serial="DISK1") - b.wait_in_text("#drives", dev) + b.wait_visible(self.card_row("Storage", location=dev)) # Create a logical volume that we will later grow m.execute(f"vgcreate vgroup0 {dev}; lvcreate vgroup0 -n lvol0 -L 1500000256b") - b.wait_in_text("#devices", "vgroup0") + b.wait_visible(self.card_row("Storage", name="lvol0")) # Create a pool self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create Stratis pool"), expect=lambda: self.dialog_is_present('disks', "lvol0"), values={"disks": {"lvol0": True}}) - b.wait_in_text("#devices", "pool0") - b.wait_in_text("#devices", "1.50 GB Stratis pool") + b.wait_in_text(self.card_row("Storage", name="pool0"), "1.50 GB") # Grow the logical volume in Cockpit, the pool should grow automatically - b.click('.sidepanel-row:contains("vgroup0")') - b.wait_visible('#storage-detail') - self.content_tab_action(1, 1, "Grow") + b.click(self.card_row("Storage", name="lvol0")) + b.click(self.card_button("Logical volume", "Grow")) self.dialog({"size": 1600}) b.go("#/") - b.wait_in_text("#devices", "1.60 GB Stratis pool") + b.wait_in_text(self.card_row("Storage", name="pool0"), "1.60 GB") # Grow the logical volume from outside of Cockpit, the pool should complain m.execute("lvresize vgroup0/lvol0 -L +100000256b") - b.wait_visible('.sidepanel-row:contains(pool0) .ct-icon-exclamation-triangle') - b.click('.sidepanel-row:contains("pool0")') - b.wait_visible('#storage-detail') + b.wait_visible(self.card_row("Storage", name="pool0") + ' .ct-icon-exclamation-triangle') + b.click(self.card_row("Storage", name="pool0")) b.wait_visible('.pf-v5-c-alert:contains("This pool does not use all the space")') b.click('button:contains("Grow the pool")') b.wait_not_present('.pf-v5-c-alert') - b.wait_in_text("#detail-header", "1.7 GB") + b.wait_in_text(self.card_desc("Stratis pool", "Usage"), "1.7 GB") b.go("#/") # Grow the logical volume from outside of Cockpit, the logical volume should also complain m.execute("lvresize vgroup0/lvol0 -L +100000256b") - b.wait_visible('.sidepanel-row:contains(vgroup0) .ct-icon-exclamation-triangle') - b.click('.sidepanel-row:contains("vgroup0")') - b.wait_visible('#storage-detail') - vol_tab = self.content_tab_expand(1, 1) + b.wait_visible(self.card_row("Storage", name="lvol0") + " .ct-icon-exclamation-triangle") + b.click(self.card_row("Storage", name="lvol0")) # First shrink the volume to test whether Cockpit can figure out the right size for that - b.wait_in_text("#detail-content td[data-label=Size]", "1.80 GB") - b.wait_visible(vol_tab + " button:contains(Shrink volume)") - self.content_tab_action(1, 1, "Shrink volume") - b.wait_in_text("#detail-content td[data-label=Size]", "1.70 GB") - b.wait_not_present(vol_tab + " button:contains(Shrink volume)") + b.click(self.card_button("Logical volume", "Shrink volume")) + b.wait_in_text(self.card_desc("Logical volume", "Size"), "1.70 GB") + b.wait_not_present(self.card_button("Logical volume", "Shrink volume")) # Then enlarge the volume from the outside again and grow the blockdev m.execute("lvresize vgroup0/lvol0 -L +100000256b") - b.wait_in_text("#detail-content td[data-label=Size]", "1.80 GB") - b.wait_visible(vol_tab + " button:contains(Grow content)") - self.content_tab_action(1, 1, "Grow content") - vol_tab = self.content_tab_expand(1, 1) - b.wait_not_present(vol_tab + " button:contains(Grow content)") + b.click(self.card_button("Logical volume", "Grow content")) + b.wait_not_present(self.card_button("Logical volume", "Grow content")) b.go("#/") - b.wait_in_text("#devices", "1.80 GB Stratis pool") + b.wait_in_text(self.card_row("Storage", name="pool0"), "1.80 GB") @testlib.skipImage("No Stratis", "debian-*", "ubuntu-*", "arch") @@ -771,7 +749,7 @@ class TestStoragePackagesStratis(packagelib.PackageCase, storagelib.StorageCase) dev_1 = "/dev/sda" m.add_disk("4G", serial="DISK1") - b.wait_in_text("#drives", dev_1) + b.wait_visible(self.card_row("Storage", location=dev_1)) if ondemand_stratis: self.devices_dropdown("Create Stratis pool") @@ -783,11 +761,14 @@ class TestStoragePackagesStratis(packagelib.PackageCase, storagelib.StorageCase) self.dialog_set_val("disks", {dev_1: True}) self.dialog_apply() self.dialog_wait_close() - b.wait_in_text("#devices", "pool0") + b.wait_visible(self.card_row("Storage", name="pool0")) else: - b.click("#devices .pf-v5-c-dropdown button.pf-v5-c-dropdown__toggle") - b.wait_visible("#devices .pf-v5-c-dropdown a:contains('Create RAID device')") - b.wait_not_present("#devices .pf-v5-c-dropdown a:contains('Create Stratis pool')") + # XXX - explain this or make it more explicit + raid_clicks = self.dropdown_action(self.card_header("Storage"), "Create RAID device") + stratic_clicks = self.dropdown_action(self.card_header("Storage"), "Create Stratis pool") + b.click(raid_clicks[0]) + b.wait_visible(raid_clicks[1]) + b.wait_not_present(stratis_clicks[1]) @testlib.skipImage("No Stratis", "debian-*", "ubuntu-*") @@ -810,6 +791,8 @@ class TestStorageStratisNBDE(packagelib.PackageCase, storagelib.StorageCase): m = self.machine b = self.browser + m.upload(["/home/mvo/work/cockpit/dist/storaged"], "/usr/share/cockpit") + tang_m = self.machines["tang"] tang_m.execute("systemctl start tangd.socket") tang_m.execute("firewall-cmd --add-port 80/tcp") @@ -818,11 +801,11 @@ class TestStorageStratisNBDE(packagelib.PackageCase, storagelib.StorageCase): dev_1 = "/dev/sda" m.add_disk("4G", serial="DISK1") - b.wait_in_text("#drives", dev_1) + b.wait_visible(self.card_row("Storage", location=dev_1)) dev_2 = "/dev/sdb" m.add_disk("5G", serial="DISK2") - b.wait_in_text("#drives", dev_2) + b.wait_visible(self.card_row("Storage", location=dev_2)) # Create an encrypted pool with both a passphrase and a keyserver self.dialog_open_with_retry(trigger=lambda: self.devices_dropdown("Create Stratis pool"), @@ -841,50 +824,45 @@ class TestStorageStratisNBDE(packagelib.PackageCase, storagelib.StorageCase): with b.wait_timeout(60): self.dialog_wait_close() - b.wait_in_text("#devices", "pool0") - b.click('.sidepanel-row:contains("pool0")') - b.wait_visible('#storage-detail') - b.wait_in_text('#detail-header', "Encrypted Stratis pool pool0") - b.wait_in_text('#detail-header', "Passphrase") - b.wait_in_text('#detail-header', "Keyserver") - b.wait_in_text('#detail-header', "10.111.112.5") + b.click(self.card_row("Storage", name="pool0")) + b.wait_visible(self.card_desc("Encrypted Stratis pool", "Passphrase")) + b.wait_in_text(self.card_desc("Encrypted Stratis pool", "Keyserver"), "10.111.112.5") b.assert_pixels('#detail-header', "header", ignore=['.pf-v5-c-description-list__group:contains(UUID)']) # Remove passphrase - b.click('#detail-header .pf-v5-c-description-list__group:contains(Passphrase) button:contains(Remove)') + b.click(self.card_desc("Encrypted Stratis pool", "Passphrase") + " button:contains(Remove)") self.confirm() - b.wait_in_text('#detail-header .pf-v5-c-description-list__group:contains(Passphrase)', "Add passphrase") - b.wait_visible('#detail-header .pf-v5-c-description-list__group:contains(Keyserver) button:contains(Remove)[aria-disabled=true]') + b.wait_visible(self.card_desc("Encrypted Stratis pool", "Passphrase") + " button:contains(Add passphrase)") + b.wait_visible(self.card_desc("Encrypted Stratis pool", "Keyserver") + " button:contains(Remove):disabled") # Stop the pool and start it again. This should not ask # for the passphrase (since there isn't any) m.execute("stratis pool stop pool0") - b.wait_in_text('#detail-header', "Stopped Stratis pool") + b.wait_visible(self.card("Stopped Stratis pool")) tang_m.execute("systemctl stop tangd.socket") - b.click('#detail-header button:contains(Start)') + b.click(self.card_button("Stopped Stratis pool", "Start")) self.dialog_wait_open() b.wait_in_text("#dialog", "Error communicating") self.dialog_cancel() self.dialog_wait_close() tang_m.execute("systemctl start tangd.socket") - b.click('#detail-header button:contains(Start)') - b.wait_not_in_text('#detail-header', "Stopped") - b.wait_in_text('#detail-header', "Encrypted Stratis pool pool0") + b.click(self.card_button("Stopped Stratis pool", "Start")) + b.wait_visible(self.card("Encrypted Stratis pool")) - # Put passphrase back and do the stopping starting again, - # but without tang. This should try clevis but then fall + # Put passphrase back and do the stopping starting again, but + # without tangd running. This should try clevis but then fall # back to asking for a passphrase. - b.click('#detail-header .pf-v5-c-description-list__group:contains(Passphrase) button:contains(Add passphrase)') + b.click(self.card_desc("Encrypted Stratis pool", "Passphrase") + " button:contains(Add passphrase)") self.dialog({'passphrase': "foodeeboodeebar", 'passphrase2': "foodeeboodeebar"}) - b.wait_visible('#detail-header .pf-v5-c-description-list__group:contains(Keyserver) button:contains(Remove):not([aria-disabled=true])') + b.wait_visible(self.card_desc("Encrypted Stratis pool", "Passphrase") + " button:contains(Remove):not(:disabled)") m.execute("stratis pool stop pool0") tang_m.execute("systemctl stop tangd.socket") - b.click('#detail-header button:contains(Start)') + b.click(self.card_button("Stopped Stratis pool", "Start")) self.dialog_wait_open() self.dialog_set_val("passphrase", "foobar") self.dialog_cancel() @@ -892,21 +870,20 @@ class TestStorageStratisNBDE(packagelib.PackageCase, storagelib.StorageCase): # Finally start tang tang_m.execute("systemctl start tangd.socket") - b.click('#detail-header button:contains(Start)') - b.wait_not_in_text('#detail-header', "Stopped") - b.wait_in_text('#detail-header', "Encrypted Stratis pool pool0") + b.click(self.card_button("Stopped Stratis pool", "Start")) + b.wait_visible(self.card("Encrypted Stratis pool")) # Add a blockdevice. This requires the passphrase. - b.click('#detail-sidebar .pf-v5-c-card__actions button') + b.click(self.card_button("Block devices", "Add block device")) self.dialog({'disks': {dev_2: True}, 'passphrase': "foodeeboodeebar"}) # Remove the keyserver and add it back - b.click('#detail-header .pf-v5-c-description-list__group:contains(Keyserver) button:contains(Remove)') + b.click(self.card_desc("Encrypted Stratis pool", "Keyserver") + " button:contains(Remove)") self.confirm() - b.click('#detail-header button:contains(Add keyserver)') + b.click(self.card_desc("Encrypted Stratis pool", "Keyserver") + " button:contains(Add keyserver)") self.dialog_wait_open() self.dialog_set_val("tang_url", "10.111.112.5") self.dialog_set_val("passphrase", "foodeeboodeebar") @@ -916,16 +893,16 @@ class TestStorageStratisNBDE(packagelib.PackageCase, storagelib.StorageCase): self.dialog_apply() with b.wait_timeout(60): self.dialog_wait_close() - b.wait_in_text('#detail-header', "10.111.112.5") + b.wait_in_text(self.card_desc("Encrypted Stratis pool", "Keyserver"), "10.111.112.5") # Remove the keyserver and add it back a second time, but try # first with the wrong passphrase already in the keyring - b.click('#detail-header .pf-v5-c-description-list__group:contains(Keyserver) button:contains(Remove)') + b.click(self.card_desc("Encrypted Stratis pool", "Keyserver") + " button:contains(Remove)") self.confirm() m.execute("echo foobar | stratis key set pool0 --capture-key") - b.click('#detail-header button:contains(Add keyserver)') + b.click(self.card_desc("Encrypted Stratis pool", "Keyserver") + " button:contains(Add keyserver)") self.dialog_wait_open() self.dialog_set_val("tang_url", "10.111.112.5") self.dialog_apply() @@ -939,21 +916,23 @@ class TestStorageStratisNBDE(packagelib.PackageCase, storagelib.StorageCase): self.dialog_apply() with b.wait_timeout(60): self.dialog_wait_close() - b.wait_in_text('#detail-header', "10.111.112.5") + b.wait_in_text(self.card_desc("Encrypted Stratis pool", "Keyserver"), "10.111.112.5") m.execute("stratis key unset pool0") # Create a mounted filesystem and reboot. - b.click("button:contains(Create new filesystem)") + b.click(self.card_button("Filesystems", "Create new filesystem")) self.dialog({'name': 'fsys1', 'mount_point': '/run/fsys1'}) - b.wait_in_text("#detail-content", "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 1), "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys1") m.reboot() m.start_cockpit() b.relogin() b.enter_page("/storage") - b.wait_visible("#storage-detail") - self.wait_mounted(1, 1) # should be mounted after boot + b.wait_visible(self.card("Encrypted Stratis pool")) # should be started after boot + b.wait_text(self.card_row_col("Filesystems", 1, 1), "fsys1") + b.wait_text(self.card_row_col("Filesystems", 1, 3), "/run/fsys1") # should be mounted after boot if __name__ == '__main__': diff --git a/test/verify/check-storage-swap b/test/verify/check-storage-swap index 1fdd73ac5479..c318bfe8a862 100755 --- a/test/verify/check-storage-swap +++ b/test/verify/check-storage-swap @@ -22,22 +22,31 @@ import testlib @testlib.nondestructive -@testlib.onlyImage("No zram by default", "fedora-*") class TestStorageswap(storagelib.StorageCase): - def testZram(self): + def test(self): m = self.machine b = self.browser - self.assertEqual(m.execute("lsblk -n -o MOUNTPOINTS /dev/zram0").strip(), "[SWAP]") + m.upload(["/home/mvo/work/cockpit/dist/storaged"], "/usr/share/cockpit") - # Cockpit should recognize that zram0 is used as swap - # eventhough it does not have a swap header on it. self.login_and_go("/storage") - b.go("#/zram0") - b.wait_visible('#storage-detail') - self.content_row_wait_in_col(1, 2, "Swap space") - self.content_tab_wait_in_info(1, 1, "Used", "") + + disk = self.add_ram_disk() + b.click(self.card_row("Storage", name=disk)) + + b.wait_visible(self.card("Unrecognized data")) + m.execute(f"mkswap {disk}") + b.wait_visible(self.card("Swap")) + b.wait_text(self.card_desc("Swap", "Used"), "-") + + b.click(self.card_button("Swap", "Start")) + b.wait_text(self.card_desc("Swap", "Used"), "0") + + self.assertEqual(m.execute(f"lsblk -n -o MOUNTPOINTS {disk}").strip(), "[SWAP]") + + b.click(self.card_button("Swap", "Stop")) + b.wait_text(self.card_desc("Swap", "Used"), "-") 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 65af0da9082d..3d90fe7280fc 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"