diff --git a/pkg/storaged/block-details.jsx b/pkg/storaged/block-details.jsx deleted file mode 100644 index f9deed2b6ddc..000000000000 --- a/pkg/storaged/block-details.jsx +++ /dev/null @@ -1,58 +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 cockpit from "cockpit"; -import React from "react"; - -import { Card, CardBody, CardTitle } 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 * as utils from "./utils.js"; -import { StdDetailsLayout } from "./details.jsx"; -import * as Content from "./content-views.jsx"; - -const _ = cockpit.gettext; - -export class BlockDetails extends React.Component { - render() { - const block = this.props.block; - - const header = ( - - {_("Block")} - - - - {_("storage", "Capacity")} - { utils.fmt_size_long(block.Size) } - - - {_("storage", "Device file")} - { utils.block_name(block) } - - - - - ); - - const content = ; - - return ; - } -} diff --git a/pkg/storaged/block/create-pages.jsx b/pkg/storaged/block/create-pages.jsx new file mode 100644 index 000000000000..2ab82396eca1 --- /dev/null +++ b/pkg/storaged/block/create-pages.jsx @@ -0,0 +1,110 @@ +/* + * 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 client from "../client"; + +import { get_fstab_config } from "../filesystem/utils.jsx"; + +import { make_partition_table_page } from "../partitions/partition-table.jsx"; +import { make_legacy_vdo_page } from "../legacy-vdo/legacy-vdo.jsx"; + +import { make_unrecognized_data_card } from "./unrecognized-data.jsx"; +import { make_unformatted_data_card } from "./unformatted-data.jsx"; +import { make_locked_encrypted_data_card } from "../crypto/locked-encrypted-data.jsx"; +import { make_filesystem_card } from "../filesystem/filesystem.jsx"; +import { make_lvm2_physical_volume_card } from "../lvm2/physical-volume.jsx"; +import { make_mdraid_disk_card } from "../mdraid/mdraid-disk.jsx"; +import { make_stratis_blockdev_card } from "../stratis/blockdev.jsx"; +import { make_swap_card } from "../swap/swap.jsx"; +import { make_encryption_card } from "../crypto/encryption.jsx"; + +import { new_page } from "../pages.jsx"; + +/* CARD must have page_name, page_location, and page_size set. + */ + +export function make_block_page(parent, block, card) { + 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 legacy_vdo = client.legacy_vdo_overlay.find_by_backing_block(block); + + 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); + + if (client.blocks_ptable[block.path]) { + make_partition_table_page(parent, block, card); + return; + } + + if (legacy_vdo) { + make_legacy_vdo_page(parent, legacy_vdo, block, card); + return; + } + + // Adjust for encryption leaking out of Stratis + if (is_crypto && is_stratis) { + is_crypto = false; + content_block = block; + } + + if (is_crypto) + card = make_encryption_card(card, block); + + if (!content_block) { + if (!is_crypto) { + // can not happen unless there is a bug in the code above. + console.error("Assertion failure: is_crypto == false"); + } + if (fstab_config.length > 0) { + card = make_filesystem_card(card, block, null, fstab_config); + } else { + card = make_locked_encrypted_data_card(card, block); + } + } else { + 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) { + card = make_filesystem_card(card, block, content_block, fstab_config); + } else if ((content_block.IdUsage == "raid" && content_block.IdType == "LVM2_member") || + (block_pvol && client.vgroups[block_pvol.VolumeGroup])) { + card = make_lvm2_physical_volume_card(card, block, content_block); + } else if (is_stratis) { + card = make_stratis_blockdev_card(card, block, content_block); + } else if ((content_block.IdUsage == "raid") || + (client.mdraids[content_block.MDRaidMember])) { + card = make_mdraid_disk_card(card, block, content_block); + } else if (block_swap || + (content_block.IdUsage == "other" && content_block.IdType == "swap")) { + card = make_swap_card(card, block, content_block); + } else if (client.blocks_available[content_block.path]) { + card = make_unformatted_data_card(card, block, content_block); + } else { + card = make_unrecognized_data_card(card, block, content_block); + } + } + + new_page(parent, card); +} diff --git a/pkg/storaged/format-dialog.jsx b/pkg/storaged/block/format-dialog.jsx similarity index 92% rename from pkg/storaged/format-dialog.jsx rename to pkg/storaged/block/format-dialog.jsx index ce7d2369f433..4b21aba617b4 100644 --- a/pkg/storaged/format-dialog.jsx +++ b/pkg/storaged/block/format-dialog.jsx @@ -18,8 +18,13 @@ */ import cockpit from "cockpit"; -import * as utils from "./utils.js"; -import { edit_crypto_config, parse_options, unparse_options, extract_option } from "./utils.js"; +import { + edit_crypto_config, parse_options, unparse_options, extract_option, + get_parent_blocks, is_netdev, + decode_filename, encode_filename, block_name, + get_active_usage, reload_systemd, teardown_active_usage, + validate_fsys_label, +} from "../utils.js"; import React from "react"; import { FormHelperText } from "@patternfly/react-core/dist/esm/components/Form/index.js"; @@ -31,18 +36,18 @@ import { TextInput, PassInput, CheckBoxes, SelectOne, SizeSlider, BlockingMessage, TeardownMessage, init_active_usage_processes -} from "./dialog.jsx"; +} from "../dialog.jsx"; -import { get_fstab_config, is_valid_mount_point } from "./fsys-tab.jsx"; -import { init_existing_passphrase, unlock_with_type } from "./crypto-keyslots.jsx"; -import { job_progress_wrapper } from "./jobs-panel.jsx"; +import { get_fstab_config, is_valid_mount_point } from "../filesystem/utils.jsx"; +import { init_existing_passphrase, unlock_with_type } from "../crypto/keyslots.jsx"; +import { job_progress_wrapper } from "../jobs-panel.jsx"; const _ = cockpit.gettext; export function initial_tab_options(client, block, for_fstab) { const options = { }; - utils.get_parent_blocks(client, block.path).forEach(p => { + get_parent_blocks(client, block.path).forEach(p => { // "nofail" is the default for new filesystems with Cockpit so // that a failure to mount one of them will not prevent // Cockpit from starting. This allows people to debug and fix @@ -50,7 +55,7 @@ export function initial_tab_options(client, block, for_fstab) { // options.nofail = true; - if (utils.is_netdev(client, p)) { + if (is_netdev(client, p)) { options._netdev = true; } // HACK - https://bugzilla.redhat.com/show_bug.cgi?id=1589541 @@ -126,7 +131,7 @@ export const mount_explanation = { export function format_dialog(client, path, start, size, enable_dos_extended) { const block = client.blocks[path]; if (block.IdUsage == "crypto") { - cockpit.spawn(["cryptsetup", "luksDump", utils.decode_filename(block.Device)], { superuser: true }) + cockpit.spawn(["cryptsetup", "luksDump", decode_filename(block.Device)], { superuser: true }) .then(output => { if (output.indexOf("Keyslots:") >= 0) // This is what luksmeta-monitor-hack looks for return 2; @@ -155,9 +160,9 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, let title; if (create_partition) - title = cockpit.format(_("Create partition on $0"), utils.block_name(block)); + title = cockpit.format(_("Create partition on $0"), block_name(block)); else - title = cockpit.format(_("Format $0"), utils.block_name(block)); + title = cockpit.format(_("Format $0"), block_name(block)); function is_filesystem(vals) { return vals.type != "empty" && vals.type != "dos-extended"; @@ -206,11 +211,11 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, add_crypto_type("luks1", "LUKS1", false); add_crypto_type("luks2", "LUKS2", true); - const usage = utils.get_active_usage(client, create_partition ? null : path, _("format"), _("delete")); + const usage = get_active_usage(client, create_partition ? null : path, _("format"), _("delete")); if (usage.Blocking) { dialog_open({ - Title: cockpit.format(_("$0 is in use"), utils.block_name(block)), + Title: cockpit.format(_("$0 is in use"), block_name(block)), Body: BlockingMessage(usage) }); return; @@ -219,7 +224,7 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, const crypto_config = block.Configuration.find(c => c[0] == "crypttab"); let crypto_options; if (crypto_config) { - crypto_options = (utils.decode_filename(crypto_config[1].options.v) + crypto_options = (decode_filename(crypto_config[1].options.v) .split(",") .filter(function (s) { return s.indexOf("x-parent") !== 0 }) .join(",")); @@ -266,7 +271,7 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, Fields: [ TextInput("name", _("Name"), { - validate: (name, vals) => utils.validate_fsys_label(name, vals.type), + validate: (name, vals) => validate_fsys_label(name, vals.type), visible: is_filesystem }), TextInput("mount_point", _("Mount point"), @@ -429,7 +434,7 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, } opts = opts.concat(parse_options(vals.crypto_options)); - new_crypto_options = { t: 'ay', v: utils.encode_filename(unparse_options(opts)) }; + new_crypto_options = { t: 'ay', v: encode_filename(unparse_options(opts)) }; const item = { options: new_crypto_options, "track-parents": { t: 'b', v: true } @@ -437,9 +442,9 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, if (!keep_keys) { if (vals.store_passphrase.on) { - item["passphrase-contents"] = { t: 'ay', v: utils.encode_filename(vals.passphrase) }; + item["passphrase-contents"] = { t: 'ay', v: encode_filename(vals.passphrase) }; } else { - item["passphrase-contents"] = { t: 'ay', v: utils.encode_filename("") }; + item["passphrase-contents"] = { t: 'ay', v: encode_filename("") }; } config_items.push(["crypttab", item]); options["encrypt.passphrase"] = { t: 's', v: vals.passphrase }; @@ -471,9 +476,9 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, mount_point = "/" + mount_point; config_items.push(["fstab", { - dir: { t: 'ay', v: utils.encode_filename(mount_point) }, - type: { t: 'ay', v: utils.encode_filename("auto") }, - opts: { t: 'ay', v: utils.encode_filename(mount_options.join(",") || "defaults") }, + 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 }, "track-parents": { t: 'b', v: true } @@ -547,10 +552,10 @@ function format_dialog_internal(client, path, start, size, enable_dos_extended, .then(block_crypto => block_crypto.Lock({ }))); } - return utils.teardown_active_usage(client, usage) - .then(utils.reload_systemd) + return teardown_active_usage(client, usage) + .then(reload_systemd) .then(format) - .then(new_path => utils.reload_systemd().then(() => new_path)) + .then(new_path => reload_systemd().then(() => new_path)) .then(maybe_mount); } }, diff --git a/pkg/storaged/block/other.jsx b/pkg/storaged/block/other.jsx new file mode 100644 index 000000000000..fa62110113de --- /dev/null +++ b/pkg/storaged/block/other.jsx @@ -0,0 +1,61 @@ +/* + * 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 { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; + +import { StorageCard, StorageDescription, new_card } from "../pages.jsx"; +import { block_name } from "../utils.js"; +import { partitionable_block_actions } from "../partitions/actions.jsx"; + +import { make_block_page } from "../block/create-pages.jsx"; + +const _ = cockpit.gettext; + +export function make_other_page(parent, block) { + const other_card = new_card({ + title: _("Block device"), + next: null, + page_block: block, + for_summary: true, + job_path: block.path, + component: OtherCard, + props: { block }, + actions: partitionable_block_actions(block), + }); + + make_block_page(parent, block, other_card); +} + +const OtherCard = ({ card, block }) => { + return ( + + + + > 8) + ":" + (block.DeviceNumber & 0xFF)} /> + + + + + ); +}; diff --git a/pkg/storaged/resize.jsx b/pkg/storaged/block/resize.jsx similarity index 85% rename from pkg/storaged/resize.jsx rename to pkg/storaged/block/resize.jsx index 9711e71b7fc3..441b459a1916 100644 --- a/pkg/storaged/resize.jsx +++ b/pkg/storaged/block/resize.jsx @@ -19,23 +19,93 @@ import React from "react"; import cockpit from "cockpit"; +import client from "../client.js"; + import { block_name, get_active_usage, teardown_active_usage, undo_temporary_teardown, is_mounted_synch, get_partitions -} from "./utils.js"; +} from "../utils.js"; import { existing_passphrase_fields, init_existing_passphrase, request_passphrase_on_error_handler -} from "./crypto-keyslots.jsx"; +} from "../crypto/keyslots.jsx"; import { dialog_open, SizeSlider, BlockingMessage, TeardownMessage, SelectSpaces, init_active_usage_processes -} from "./dialog.jsx"; -import { std_reply } from "./stratis-utils.js"; -import { pvs_to_spaces } from "./content-views.jsx"; +} from "../dialog.jsx"; +import { std_reply } from "../stratis/utils.jsx"; +import { pvs_to_spaces } from "../lvm2/utils.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; @@ -81,7 +151,7 @@ function lvol_or_part_and_fsys_resize(client, lvol_or_part, size, offline, passp // HACK - https://bugzilla.redhat.com/show_bug.cgi?id=1934567 // // block_fsys.MountedAt might be out of synch with reality - // here if resizing the crypto container accidentally + // here if resizing the crypto card accidentally // triggered an unmount. Thus, we check synchronously // whether or not we should be doing a offline resize or // not. @@ -117,6 +187,9 @@ function lvol_or_part_and_fsys_resize(client, lvol_or_part, size, offline, passp return Promise.reject(_("Stratis blockdevs can not be made smaller")); // not-covered: safety check else return Promise.resolve(); + } else if (client.blocks_available[block.path]) { + // Growing or shrinking unformatted data, nothing to do + return Promise.resolve(); } else if (size < orig_size) { // This shouldn't happen. But if it does, continuing is harmful, so we throw an error. return Promise.reject(_("Unrecognized data can not be made smaller here.")); // not-covered: safety check @@ -170,7 +243,7 @@ export function get_resize_info(client, block, to_fit) { if (!cleartext) { info = { }; - shrink_excuse = grow_excuse = _("Encrypted volumes need to be unlocked before they can be resized."); + shrink_excuse = grow_excuse = _("Unlock before resizing"); } else { return get_resize_info(client, cleartext, to_fit); } @@ -179,15 +252,20 @@ export function get_resize_info(client, block, to_fit) { if (!info) { info = { }; - shrink_excuse = grow_excuse = cockpit.format(_("$0 filesystems can not be resized here."), + shrink_excuse = grow_excuse = cockpit.format(_("$0 can not be resized here"), block.IdType); } else { - if (!info.can_shrink) - shrink_excuse = cockpit.format(_("$0 filesystems can not be made smaller."), - block.IdType); - if (!info.can_grow) - grow_excuse = cockpit.format(_("$0 filesystems can not be made larger."), - block.IdType); + if (!info.can_shrink && !info.can_grow) { + shrink_excuse = grow_excuse = cockpit.format(_("$0 can not be resized"), + block.IdType); + } else { + if (!info.can_shrink) + shrink_excuse = cockpit.format(_("$0 can not be made smaller"), + block.IdType); + if (!info.can_grow) + grow_excuse = cockpit.format(_("$0 can not be made larger"), + block.IdType); + } } } else if (client.blocks_stratis_blockdev[block.path] && client.features.stratis_grow_blockdevs) { info = { @@ -198,7 +276,7 @@ export function get_resize_info(client, block, to_fit) { shrink_excuse = _("Stratis blockdevs can not be made smaller"); } else if (block.IdUsage == 'raid') { info = { }; - shrink_excuse = grow_excuse = _("Physical volumes can not be resized here."); + shrink_excuse = grow_excuse = _("Physical volumes can not be resized here"); } else if (client.legacy_vdo_overlay.find_by_backing_block(block)) { info = { can_shrink: false, @@ -206,13 +284,20 @@ export function get_resize_info(client, block, to_fit) { grow_needs_unmount: false }; shrink_excuse = _("VDO backing devices can not be made smaller"); + } else if (client.blocks_available[block.path]) { + info = { + can_shrink: true, + can_grow: true, + shrink_needs_unmount: false, + grow_needs_unmount: false, + }; } else { info = { can_shrink: false, can_grow: true, grow_needs_unmount: true }; - shrink_excuse = _("Unrecognized data can not be made smaller here."); + shrink_excuse = _("Unrecognized data can not be made smaller here"); } if (to_fit) { // Shrink to fit doesn't need to resize the content @@ -220,7 +305,7 @@ export function get_resize_info(client, block, to_fit) { } } else { info = { }; - shrink_excuse = grow_excuse = _("This volume needs to be activated before it can be resized."); + shrink_excuse = grow_excuse = _("Activate before resizing"); } return { info, shrink_excuse, grow_excuse }; diff --git a/pkg/storaged/block/unformatted-data.jsx b/pkg/storaged/block/unformatted-data.jsx new file mode 100644 index 000000000000..7c4e5ed12c01 --- /dev/null +++ b/pkg/storaged/block/unformatted-data.jsx @@ -0,0 +1,39 @@ +/* + * 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 { StorageCard, new_card } from "../pages.jsx"; +import { format_dialog } from "./format-dialog.jsx"; +import { std_lock_action } from "../crypto/actions.jsx"; + +const _ = cockpit.gettext; + +export function make_unformatted_data_card(next, backing_block, content_block) { + return new_card({ + title: _("Unformatted data"), + next, + component: StorageCard, + actions: [ + std_lock_action(backing_block, content_block), + { title: _("Format"), action: () => format_dialog(client, backing_block.path), danger: true }, + ] + }); +} diff --git a/pkg/storaged/block/unrecognized-data.jsx b/pkg/storaged/block/unrecognized-data.jsx new file mode 100644 index 000000000000..f5056d6524f9 --- /dev/null +++ b/pkg/storaged/block/unrecognized-data.jsx @@ -0,0 +1,57 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { StorageCard, StorageDescription, new_card } from "../pages.jsx"; +import { format_dialog } from "./format-dialog.jsx"; +import { std_lock_action } from "../crypto/actions.jsx"; + +const _ = cockpit.gettext; + +export function make_unrecognized_data_card(next, backing_block, content_block) { + return new_card({ + title: _("Unrecognized data"), + next, + component: UnrecognizedDataCard, + 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 UnrecognizedDataCard = ({ card, backing_block, content_block }) => { + return ( + + + + + + + + + ); +}; diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js index 99aaba59c424..9772851f7fe2 100644 --- a/pkg/storaged/client.js +++ b/pkg/storaged/client.js @@ -26,14 +26,15 @@ import * as utils from './utils.js'; import * as python from "python.js"; import { read_os_release } from "os-release.js"; -import { find_warnings } from "./warnings.jsx"; - import inotify_py from "inotify.py"; import mount_users_py from "./mount-users.py"; -import nfs_mounts_py from "./nfs-mounts.py"; -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 nfs_mounts_py from "./nfs/nfs-mounts.py"; +import vdo_monitor_py from "./legacy-vdo/vdo-monitor.py"; +import stratis2_set_key_py from "./stratis/stratis2-set-key.py"; +import stratis3_set_key_py from "./stratis/stratis3-set-key.py"; + +import { reset_pages } from "./pages.jsx"; +import { make_overview_page } from "./overview/overview.jsx"; /* STORAGED CLIENT */ @@ -562,18 +563,40 @@ 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.blocks_available = { }; + for (path in client.blocks) { + block = client.blocks[path]; + if (utils.is_available_block(client, block)) + client.blocks_available[path] = true; + } + client.path_jobs = { }; function enter_job(job) { if (!job.Objects || !job.Objects.length) return; - job.Objects.forEach(function (path) { - client.path_jobs[path] = job; - let parent = utils.get_parent(client, path); - while (parent) { - path = parent; - parent = utils.get_parent(client, path); - } - client.path_jobs[path] = job; + job.Objects.forEach(p => { + if (!client.path_jobs[p]) + client.path_jobs[p] = []; + client.path_jobs[p].push(job); }); } for (path in client.jobs) { @@ -581,10 +604,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(); + reset_pages(); + make_overview_page(); + client.dispatchEvent("changed"); + } }; function init_model(callback) { @@ -723,7 +751,7 @@ function init_model(callback) { client.storaged_client.addEventListener('notify', () => client.update()); - client.update(); + client.update(true); callback(); }); }); @@ -819,7 +847,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) { @@ -842,11 +870,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/content-views.jsx b/pkg/storaged/content-views.jsx deleted file mode 100644 index 9f62be6b3a5d..000000000000 --- a/pkg/storaged/content-views.jsx +++ /dev/null @@ -1,1196 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2016 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 { - dialog_open, TextInput, PassInput, SelectOne, SelectOneRadioVertical, SizeSlider, CheckBoxes, - SelectSpaces, BlockingMessage, TeardownMessage, Message, - init_active_usage_processes -} from "./dialog.jsx"; -import * as utils from "./utils.js"; -import { set_crypto_auto_option } from "./utils.js"; - -import React from "react"; -import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js'; -import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js"; -import { - DropdownSeparator -} from '@patternfly/react-core/dist/esm/deprecated/components/Dropdown/index.js'; -import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; - -import { ListingTable } from "cockpit-components-table.jsx"; -import { ListingPanel } from 'cockpit-components-listing-panel.jsx'; -import { StorageButton, StorageBarMenu, StorageMenuItem, StorageUsageBar } from "./storage-controls.jsx"; -import * as PK from "packagekit.js"; -import { format_dialog } from "./format-dialog.jsx"; -import { job_progress_wrapper } from "./jobs-panel.jsx"; - -import { FilesystemTab, is_mounted, mounting_dialog, get_fstab_config } from "./fsys-tab.jsx"; -import { CryptoTab } from "./crypto-tab.jsx"; -import { get_existing_passphrase, unlock_with_type } from "./crypto-keyslots.jsx"; -import { BlockVolTab, PoolVolTab, VDOPoolTab } from "./lvol-tabs.jsx"; -import { PartitionTab } from "./part-tab.jsx"; -import { SwapTab } from "./swap-tab.jsx"; -import { UnrecognizedTab } from "./unrecognized-tab.jsx"; -import { warnings_icon } from "./warnings.jsx"; - -const _ = cockpit.gettext; - -const C_ = cockpit.gettext; - -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++) { - if (lvols[i].Name == name) - return lvols[i]; - } - return null; - } - - let name; - for (let i = 0; i < 1000; i++) { - name = prefix + i.toFixed(); - if (!find_lvol(name)) - break; - } - - return name; -} - -export function pvs_to_spaces(client, pvs) { - return pvs.map(pvol => { - const block = client.blocks[pvol.path]; - const parts = utils.get_block_link_parts(client, pvol.path); - const text = cockpit.format(parts.format, parts.link); - return { type: 'block', block, size: pvol.FreeSize, desc: text, pvol }; - }); -} - -function create_tabs(client, target, options) { - function endsWith(str, suffix) { - return str.indexOf(suffix, str.length - suffix.length) !== -1; - } - - const block = endsWith(target.iface, ".Block") ? target : null; - let is_crypto = (block && block.IdUsage == 'crypto'); - const content_block = is_crypto ? client.blocks_cleartext[block.path] : block; - - const block_fsys = content_block && client.blocks_fsys[content_block.path]; - const block_lvm2 = block && client.blocks_lvm2[block.path]; - const block_swap = content_block && client.blocks_swap[content_block.path]; - - const block_stratis_blockdev = block && client.blocks_stratis_blockdev[block.path]; - const block_stratis_stopped_pool = block && client.blocks_stratis_stopped_pool[block.path]; - - const lvol = (endsWith(target.iface, ".LogicalVolume") - ? target - : block_lvm2 && client.lvols[block_lvm2.LogicalVolume]); - - const is_filesystem = (content_block && content_block.IdUsage == 'filesystem'); - 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; - - let warnings = client.path_warnings[target.path] || []; - if (content_block) - warnings = warnings.concat(client.path_warnings[content_block.path] || []); - if (lvol) - warnings = warnings.concat(client.path_warnings[lvol.path] || []); - - const tab_actions = []; - const tab_menu_actions = []; - const tab_menu_danger_actions = []; - - function add_action(title, func) { - if (tab_actions.length == 0) { - tab_actions.push({title}); - tab_menu_actions.push({ title, func, only_narrow: true }); - } else { - add_menu_action(title, func); - } - } - - function add_danger_action(title, func) { - if (tab_actions.length == 0) { - tab_actions.push({title}); - tab_menu_danger_actions.push({ title, func, only_narrow: true }); - } else { - add_menu_danger_action(title, func); - } - } - - function add_menu_action(title, func) { - tab_menu_actions.push({ title, func }); - } - - function add_menu_danger_action(title, func) { - tab_menu_danger_actions.push({ title, func, danger: true }); - } - - const tabs = []; - - function add_tab(name, renderer, for_content, associated_warnings) { - let tab_warnings = []; - if (associated_warnings) - tab_warnings = warnings.filter(w => associated_warnings.indexOf(w.warning) >= 0); - if (tab_warnings.length > 0) - name =
{warnings_icon(tab_warnings)} {name}
; - tabs.push( - { - name, - renderer, - data: { - client, - block: for_content ? content_block : block, - lvol, - warnings: tab_warnings, - } - }); - } - - function create_thin() { - const vgroup = lvol && client.vgroups[lvol.VolumeGroup]; - if (!vgroup) - return; - - dialog_open({ - Title: _("Create thin volume"), - Fields: [ - TextInput("name", _("Name"), - { - value: next_default_logical_volume_name(client, vgroup, "lvol"), - validate: utils.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, { }); - } - } - }); - } - - if (lvol) { - if (lvol.Type == "pool") { - add_tab(_("Pool"), PoolVolTab); - add_action(_("Create thin volume"), create_thin); - } else { - add_tab(_("Volume"), BlockVolTab, false, ["unused-space", "partial-lvol"]); - - if (client.vdo_vols[lvol.path]) - add_tab(_("VDO pool"), VDOPoolTab); - } - } - - if (options.is_partition) { - add_tab(_("Partition"), PartitionTab, false, ["unused-space"]); - } - - let is_unrecognized = false; - - if (is_filesystem) { - add_tab(_("Filesystem"), FilesystemTab, true, ["mismounted-fsys"]); - } else if (content_block && (content_block.IdUsage == "raid" || - client.legacy_vdo_overlay.find_by_backing_block(content_block))) { - // no tab for these - } else if (block_swap || (content_block && content_block.IdUsage == "other" && content_block.IdType == "swap")) { - add_tab(_("Swap"), SwapTab, true); - } else if (content_block) { - is_unrecognized = true; - add_tab(_("Unrecognized data"), UnrecognizedTab, true); - } - - if (is_crypto) { - const config = client.blocks_crypto[block.path]?.ChildConfiguration.find(c => c[0] == "fstab"); - if (config && !content_block) - add_tab(_("Filesystem"), FilesystemTab, false, ["mismounted-fsys"]); - add_tab(_("Encryption"), CryptoTab); - } - - function lock() { - const crypto = client.blocks_crypto[block.path]; - if (!crypto) - return; - - return crypto.Lock({}).then(() => set_crypto_auto_option(block, false)); - } - - function unlock() { - const crypto = client.blocks_crypto[block.path]; - if (!crypto) - return; - - 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())); - }); - } - - 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))); - } - } - }); - } - - if (is_crypto) { - if (client.blocks_cleartext[block.path]) { - if (!block_fsys) - add_menu_action(_("Lock"), lock); - } else { - const config = client.blocks_crypto[block.path]?.ChildConfiguration.find(c => c[0] == "fstab"); - if (config && !content_block) - add_action(_("Mount"), () => mounting_dialog(client, block, "mount")); - else - add_action(_("Unlock"), unlock); - } - } - - function activate() { - return lvol.Activate({}); - } - - function deactivate() { - return lvol.Deactivate({}); - } - - function create_snapshot() { - dialog_open({ - Title: _("Create snapshot"), - Fields: [ - TextInput("name", _("Name"), - { validate: utils.validate_lvm2_name }), - ], - Action: { - Title: _("Create"), - action: function (vals) { - return lvol.CreateSnapshot(vals.name, vals.size || 0, { }); - } - } - }); - } - - function repair() { - 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."), - utils.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"), utils.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."), - utils.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), { }); - } - } - }); - } - - if (lvol) { - const status_code = client.lvols_status[lvol.path]; - if (status_code == "degraded" || status_code == "degraded-maybe-partial") - add_action(_("Repair"), repair); - - if (lvol.Type != "pool") { - if (lvol.Active) { - add_menu_action(_("Deactivate"), deactivate); - } else { - add_action(_("Activate"), activate); - } - } - if (client.lvols[lvol.ThinPool]) { - add_menu_action(_("Create snapshot"), create_snapshot); - } - } - - function swap_start() { - return block_swap.Start({}); - } - - function swap_stop() { - return block_swap.Stop({}); - } - - if (block_swap) { - if (block_swap.Active) - add_menu_action(_("Stop"), swap_stop); - else - add_menu_action(_("Start"), swap_start); - } - - function delete_() { - let block_part; - - /* This is called only for logical volumes and partitions - */ - - if (block) - block_part = client.blocks_part[block.path]; - - let name, danger; - - if (lvol) { - name = utils.lvol_name(lvol); - danger = _("Deleting a logical volume will delete all data in it."); - } else if (block_part) { - name = utils.block_name(block); - danger = _("Deleting a partition will delete all data in it."); - } - - if (name) { - const usage = utils.get_active_usage(client, target.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: danger, - Title: _("Delete"), - disable_on_error: usage.Teardown, - action: function () { - return utils.teardown_active_usage(client, usage) - .then(function () { - if (lvol) - return lvol.Delete({ 'tear-down': { t: 'b', v: true } }); - else if (block_part) - return block_part.Delete({ 'tear-down': { t: 'b', v: true } }); - }) - .then(utils.reload_systemd); - } - }, - Inits: [ - init_active_usage_processes(client, usage) - ] - }); - } - } - - if (block && !options.is_extended) { - if (is_unrecognized) - add_danger_action(_("Format"), () => format_dialog(client, block.path)); - else - add_menu_danger_action(_("Format"), () => format_dialog(client, block.path)); - } - - if (options.is_partition || lvol) { - add_menu_danger_action(_("Delete"), delete_); - } - - if (block_fsys) { - if (is_mounted(client, content_block)) - add_menu_action(_("Unmount"), () => mounting_dialog(client, content_block, "unmount")); - else - add_action(_("Mount"), () => mounting_dialog(client, content_block, "mount")); - } - - return { - renderers: tabs, - actions: tab_actions, - menu_actions: tab_menu_actions, - menu_danger_actions: tab_menu_danger_actions, - warnings - }; -} - -function block_description(client, block, options) { - let type, used_for, link, size, critical_size; - const block_stratis_blockdev = client.blocks_stratis_blockdev[block.path]; - const block_stratis_stopped_pool = client.blocks_stratis_stopped_pool[block.path]; - const vdo = client.legacy_vdo_overlay.find_by_backing_block(block); - const cleartext = client.blocks_cleartext[block.path]; - if (cleartext) - block = cleartext; - - const block_pvol = client.blocks_pvol[block.path]; - let omit_encrypted_label = false; - - size = block.Size; - - if (block.IdUsage == "crypto" && !cleartext) { - const [config, mount_point] = get_fstab_config(block, true); - if (config) { - type = C_("storage-id-desc", "Filesystem (encrypted)"); - used_for = mount_point; - } else if (block_stratis_stopped_pool) { - type = _("Stratis member"); - used_for = block_stratis_stopped_pool; - link = ["pool", used_for]; - omit_encrypted_label = true; - } else - type = C_("storage-id-desc", "Locked encrypted data"); - } else if (block.IdUsage == "filesystem") { - const [, mount_point] = get_fstab_config(block, true); - type = cockpit.format(C_("storage-id-desc", "$0 filesystem"), block.IdType); - if (client.fsys_sizes.data[mount_point]) - size = client.fsys_sizes.data[mount_point]; - used_for = mount_point; - } else if (block.IdUsage == "raid") { - if (block_pvol && client.vgroups[block_pvol.VolumeGroup]) { - const vgroup = client.vgroups[block_pvol.VolumeGroup]; - type = _("LVM2 member"); - used_for = vgroup.Name; - link = ["vg", used_for]; - size = [block_pvol.Size - block_pvol.FreeSize, block_pvol.Size]; - critical_size = 1; - } else if (client.mdraids[block.MDRaidMember]) { - const mdraid = client.mdraids[block.MDRaidMember]; - type = _("RAID member"); - used_for = utils.mdraid_name(mdraid); - link = ["mdraid", mdraid.UUID]; - } else if (block_stratis_blockdev && client.stratis_pools[block_stratis_blockdev.Pool]) { - const pool = client.stratis_pools[block_stratis_blockdev.Pool]; - type = _("Stratis member"); - used_for = pool.Name; - link = ["pool", pool.Uuid]; - omit_encrypted_label = true; - } else if (block.IdType == "LVM2_member") { - type = _("LVM2 member"); - } else if (block.IdType == "stratis") { - type = _("Stratis member"); - omit_encrypted_label = true; - } else { - type = _("RAID member"); - } - } else if (block.IdUsage == "other") { - if (block.IdType == "swap") { - type = C_("storage-id-desc", "Swap space"); - } else { - type = C_("storage-id-desc", "Other data"); - } - } else if (vdo) { - type = C_("storage-id-desc", "VDO backing"); - used_for = vdo.name; - link = ["vdo", vdo.name]; - } else if (client.blocks_swap[block.path]) { - type = C_("storage-id-desc", "Swap space"); - } else { - type = C_("storage-id-desc", "Unrecognized data"); - } - - if (cleartext && !omit_encrypted_label) - type = cockpit.format(_("$0 (encrypted)"), type); - - return { - type, - used_for, - link, - size, - critical_size - }; -} - -function append_row(client, rows, level, key, name, desc, tabs, job_object, options) { - function menuitem(action) { - if (action.title) - return {action.title}; - else - return ; - } - - let menu = null; - const menu_actions = tabs.menu_actions || []; - const menu_danger_actions = tabs.menu_danger_actions || []; - const menu_actions_wide_count = menu_actions.filter(a => !a.only_narrow).length; - const menu_danger_actions_wide_count = menu_danger_actions.filter(a => !a.only_narrow).length; - - const menu_sep = []; - if (menu_actions.length > 0 && menu_danger_actions.length > 0) - menu_sep.push({ - title: null, - only_narrow: !(menu_actions_wide_count > 0 && menu_danger_actions_wide_count > 0) - }); - - if (menu_actions.length + menu_danger_actions.length > 0) - menu = 0)} - menuItems={menu_actions.concat(menu_sep).concat(menu_danger_actions) - .map(menuitem)} - isKebab />; - - let info = null; - if (job_object && client.path_jobs[job_object]) - info = ; - if (tabs.warnings.length > 0) - info = <>{info}{warnings_icon(tabs.warnings)}; - if (info) - info = <>{"\n"}{info}; - - const cols = [ - { - title: ( - - {name} - {info} - ) - }, - { title: desc.type }, - { title: desc.link ? : desc.used_for }, - { - title: desc.size.length - ? - : utils.fmt_size(desc.size), - props: { className: "pf-v5-u-text-align-right" } - }, - { title: <>{tabs.actions}{menu}, props: { className: "pf-v5-c-table__action content-action" } }, - ]; - - rows.push({ - props: { key, className: "content-level-" + level }, - columns: cols, - expandedContent: tabs.renderers.length > 0 ? : null - }); -} - -function append_non_partitioned_block(client, rows, level, block, options) { - const tabs = create_tabs(client, block, options); - const desc = block_description(client, block, options); - - append_row(client, rows, level, block.path, utils.block_name(block), desc, tabs, block.path, options); -} - -function append_partitions(client, rows, level, block, options) { - const block_ptable = client.blocks_ptable[block.path]; - const device_level = level; - - const is_dos_partitioned = (block_ptable.Type == 'dos'); - - function append_free_space(level, start, size) { - function create_partition() { - format_dialog(client, block.path, start, size, is_dos_partitioned && level <= device_level); - } - - const btn = ( - - {_("Create partition")} - - ); - - const item = ( - - {_("Create partition")} - ); - - const menu = ; - - const cols = [ - _("Free space"), - { }, - { }, - { title: utils.fmt_size(size), props: { className: "pf-v5-u-text-align-right" } }, - { title: <>{btn}{menu}, props: { className: "pf-v5-c-table__action content-action" } }, - ]; - - rows.push({ - columns: cols, - props: { key: "free-space-" + rows.length.toString(), className: "content-level-" + level } - }); - } - - function append_extended_partition(level, partition) { - const desc = { - size: partition.size, - type: _("Extended partition") - }; - const tabs = create_tabs(client, partition.block, - { is_partition: true, is_extended: true, ...options }); - append_row(client, rows, level, partition.block.path, utils.block_name(partition.block), desc, tabs, partition.block.path, options); - process_partitions(level + 1, partition.partitions); - } - - function process_partitions(level, partitions) { - let i, p; - for (i = 0; i < partitions.length; i++) { - p = partitions[i]; - if (p.type == 'free') - append_free_space(level, p.start, p.size); - else if (p.type == 'container') - append_extended_partition(level, p); - else - append_non_partitioned_block(client, rows, level, p.block, { is_partition: true, ...options }); - } - } - - process_partitions(level, utils.get_partitions(client, block)); -} - -function append_device(client, rows, level, block, options) { - if (client.blocks_ptable[block.path]) - append_partitions(client, rows, level, block, options); - else - append_non_partitioned_block(client, rows, level, block, options); -} - -export function block_content_rows(client, block, options) { - const rows = []; - append_device(client, rows, 0, block, options); - return rows; -} - -function format_disk(client, block) { - const usage = utils.get_active_usage(client, block.path, _("initialize"), _("delete")); - - if (usage.Blocking) { - dialog_open({ - Title: cockpit.format(_("$0 is in use"), utils.block_name(block)), - Body: BlockingMessage(usage), - }); - return; - } - - dialog_open({ - Title: cockpit.format(_("Initialize disk $0"), utils.block_name(block)), - Teardown: TeardownMessage(usage), - Fields: [ - SelectOne("type", _("Partitioning"), - { - value: "gpt", - choices: [ - { value: "dos", title: _("Compatible with all systems and devices (MBR)") }, - { - value: "gpt", - title: _("Compatible with modern system and hard disks > 2TB (GPT)") - }, - { value: "empty", title: _("No partitioning") } - ] - }), - CheckBoxes("erase", _("Overwrite"), - { - fields: [ - { tag: "on", title: _("Overwrite existing data with zeros (slower)") } - ], - }), - ], - Action: { - Title: _("Initialize"), - Danger: _("Initializing erases all data on a disk."), - wrapper: job_progress_wrapper(client, block.path), - disable_on_error: usage.Teardown, - action: function (vals) { - const options = { - 'tear-down': { t: 'b', v: true } - }; - if (vals.erase.on) - options.erase = { t: 's', v: "zero" }; - return utils.teardown_active_usage(client, usage) - .then(function () { - return block.Format(vals.type, options); - }) - .then(utils.reload_systemd); - } - }, - Inits: [ - init_active_usage_processes(client, usage) - ] - }); -} - -const BlockContent = ({ client, block, allow_partitions }) => { - if (!block) - return null; - - if (block.Size === 0) - return null; - - let format_disk_btn = null; - if (allow_partitions) - format_disk_btn = ( - format_disk(client, block)} - excuse={block.ReadOnly ? _("Device is read-only") : null}> - {_("Create partition table")} - - ); - - let title; - if (client.blocks_ptable[block.path]) - title = _("Partitions"); - else - title = _("Content"); - - return ( - - - {title} - - - - - - ); -}; - -export const Block = ({ client, block, allow_partitions }) => { - return ( - - ); -}; - -function append_logical_volume_block(client, rows, level, block, lvol, options) { - const desc = client.blocks_ptable[block.path] - ? { - size: block.Size, - type: _("Partitioned block device"), - used_for: utils.block_name(block), - link: [utils.block_name(block).replace(/^\/dev\//, "")] - } - : block_description(client, block, options); - const tabs = create_tabs(client, block, options); - append_row(client, rows, level, block.path, lvol.Name, desc, tabs, block.path, options); -} - -function append_logical_volume(client, rows, level, lvol, options) { - let tabs, desc, block; - - if (lvol.Type == "pool") { - desc = { - size: lvol.Size, - type: _("Pool for thin volumes") - }; - tabs = create_tabs(client, lvol, options); - append_row(client, rows, level, lvol.Name, lvol.Name, desc, tabs, false, options); - client.lvols_pool_members[lvol.path].forEach(function (member_lvol) { - append_logical_volume(client, rows, level + 1, member_lvol, options); - }); - } else { - block = client.lvols_block[lvol.path]; - if (block) - append_logical_volume_block(client, rows, level, block, lvol, options); - 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". - - desc = { - size: lvol.Size, - type: lvol.Active ? _("Unsupported volume") : _("Inactive volume") - }; - tabs = create_tabs(client, lvol, options); - append_row(client, rows, level, lvol.Name, lvol.Name, desc, tabs, false, options); - } - } -} - -export function vgroup_content_rows(client, vgroup, options) { - const rows = []; - - 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)) - append_logical_volume(client, rows, 0, lvol, options); - }); - return rows; -} - -function install_package(name, progress) { - return PK.check_missing_packages([name], p => progress(_("Checking installed software"), p.cancel)) - .then(data => { - if (data.unavailable_names.length > 0) - return Promise.reject(new Error( - cockpit.format(_("$0 is not available from any repository."), data.unavailable_names[0]))); - // let's be cautious here, we really don't expect removals - if (data.remove_names.length > 0) - return Promise.reject(new Error( - cockpit.format(_("Installing $0 would remove $1."), name, data.remove_names[0]))); - - return PK.install_missing_packages(data, p => progress(_("Installing packages"), p.cancel)); - }); -} - -function create_logical_volume(client, vgroup) { - if (vgroup.FreeSize == 0) - return; - - const pvs_as_spaces = pvs_to_spaces(client, client.vgroups_pvols[vgroup.path].filter(pvol => pvol.FreeSize > 0)); - - const can_do_layouts = !!vgroup.CreatePlainVolumeWithLayout && pvs_as_spaces.length > 1; - - const purposes = [ - { - value: "block", - title: _("Block device for filesystems"), - }, - { value: "pool", title: _("Pool for thinly provisioned volumes") } - /* Not implemented - { value: "cache", Title: _("Cache") } - */ - ]; - - const layouts = [ - { - value: "linear", - title: _("Linear"), - min_pvs: 1, - }, - { - value: "raid0", - title: _("Striped (RAID 0)"), - min_pvs: 2, - }, - { - value: "raid1", - title: _("Mirrored (RAID 1)"), - min_pvs: 2, - }, - { - value: "raid10", - title: _("Striped and mirrored (RAID 10)"), - min_pvs: 4, - }, - { - value: "raid5", - title: _("Distributed parity (RAID 5)"), - min_pvs: 3, - }, - { - value: "raid6", - title: _("Double distributed parity (RAID 6)"), - min_pvs: 5, - } - ]; - - const vdo_package = client.get_config("vdo_package", null); - const need_vdo_install = vdo_package && !(client.features.lvm_create_vdo || client.features.legacy_vdo); - - if (client.features.lvm_create_vdo || client.features.legacy_vdo || vdo_package) - purposes.push({ value: "vdo", title: _("VDO filesystem volume (compression/deduplication)") }); - - /* For layouts with redundancy, CreatePlainVolumeWithLayout will - * create as many subvolumes as there are selected PVs. This has - * the nice effect of making the calculation of the maximum size of - * such a volume trivial. - */ - - function max_size(vals) { - const layout = vals.layout; - const pvs = vals.pvs.map(s => s.pvol); - const n_pvs = pvs.length; - const sum = pvs.reduce((sum, pv) => sum + pv.FreeSize, 0); - const min = Math.min.apply(null, pvs.map(pv => pv.FreeSize)); - - function metasize(datasize) { - const default_regionsize = 2 * 1024 * 1024; - const regions = Math.ceil(datasize / default_regionsize); - const bytes = 2 * 4096 + Math.ceil(regions / 8); - return vgroup.ExtentSize * Math.ceil(bytes / vgroup.ExtentSize); - } - - if (layout == "linear") { - return sum; - } else if (layout == "raid0" && n_pvs >= 2) { - return n_pvs * min; - } else if (layout == "raid1" && n_pvs >= 2) { - return min - metasize(min); - } else if (layout == "raid10" && n_pvs >= 4) { - return Math.floor(n_pvs / 2) * (min - metasize(min)); - } else if ((layout == "raid4" || layout == "raid5") && n_pvs >= 3) { - return (n_pvs - 1) * (min - metasize(min)); - } else if (layout == "raid6" && n_pvs >= 5) { - return (n_pvs - 2) * (min - metasize(min)); - } else - return 0; // not-covered: internal error - } - - const layout_descriptions = { - linear: _("Data will be stored on the selected physical volumes without any additional redundancy or performance improvements."), - raid0: _("Data will be stored on the selected physical volumes in an alternating fashion to improve performance. At least two volumes need to be selected."), - raid1: _("Data will be stored as two or more copies on the selected physical volumes, to improve reliability. At least two volumes need to be selected."), - raid10: _("Data will be stored as two copies and also in an alternating fashion on the selected physical volumes, to improve both reliability and performance. At least four volumes need to be selected."), - raid4: _("Data will be stored on the selected physical volumes so that one of them can be lost without affecting the data. At least three volumes need to be selected."), - raid5: _("Data will be stored on the selected physical volumes so that one of them can be lost without affecting the data. Data is also stored in an alternating fashion to improve performance. At least three volumes need to be selected."), - raid6: _("Data will be stored on the selected physical volumes so that up to two of them can be lost at the same time without affecting the data. Data is also stored in an alternating fashion to improve performance. At least five volumes need to be selected."), - }; - - function compute_layout_choices(pvs) { - return layouts.filter(l => l.min_pvs <= pvs.length); - } - - for (const lay of layouts) - lay.disabled = pvs_as_spaces.length < lay.min_pvs; - - function min_pvs_explanation(pvs, min) { - if (pvs.length <= min) - return cockpit.format(_("All $0 selected physical volumes are needed for the choosen layout."), - pvs.length); - return null; - } - - dialog_open({ - Title: _("Create logical volume"), - Fields: [ - TextInput("name", _("Name"), - { - value: next_default_logical_volume_name(client, vgroup, "lvol"), - validate: utils.validate_lvm2_name - }), - SelectOne("purpose", _("Purpose"), - { - value: "block", - choices: purposes - }), - Message(cockpit.format(_("The $0 package will be installed to create VDO devices."), vdo_package), - { - visible: vals => vals.purpose === 'vdo' && need_vdo_install, - }), - SelectSpaces("pvs", _("Physical Volumes"), - { - spaces: pvs_as_spaces, - value: pvs_as_spaces, - visible: vals => can_do_layouts && vals.purpose === 'block', - min_selected: 1, - validate: (val, vals) => { - if (vals.layout == "raid10" && (vals.pvs.length % 2) !== 0) - return _("RAID10 needs an even number of physical volumes"); - }, - explanation: min_pvs_explanation(pvs_as_spaces, 1) - }), - SelectOneRadioVertical("layout", _("Layout"), - { - value: "linear", - choices: compute_layout_choices(pvs_as_spaces), - visible: vals => can_do_layouts && vals.purpose === 'block', - explanation: layout_descriptions.linear - }), - SizeSlider("size", _("Size"), - { - visible: vals => vals.purpose !== 'vdo', - max: vgroup.FreeSize, - round: vgroup.ExtentSize - }), - /* VDO parameters */ - SizeSlider("vdo_psize", _("Size"), - { - visible: vals => vals.purpose === 'vdo', - min: 5 * 1024 * 1024 * 1024, - max: vgroup.FreeSize, - round: vgroup.ExtentSize - }), - SizeSlider("vdo_lsize", _("Logical size"), - { - visible: vals => vals.purpose === 'vdo', - value: vgroup.FreeSize, - // visually point out that this can be over-provisioned - max: vgroup.FreeSize * 3, - allow_infinite: true, - round: vgroup.ExtentSize - }), - - CheckBoxes("vdo_options", _("Options"), - { - visible: vals => vals.purpose === 'vdo', - fields: [ - { - tag: "compression", - title: _("Compression"), - tooltip: _("Save space by compressing individual blocks with LZ4") - }, - { - tag: "deduplication", - title: _("Deduplication"), - tooltip: _("Save space by storing identical data blocks just once") - }, - ], - value: { - compression: true, - deduplication: true, - } - }), - ], - update: (dlg, vals, trigger) => { - if (vals.purpose == 'block' && (trigger == "layout" || trigger == "pvs" || trigger == "purpose")) { - for (const lay of layouts) { - if (lay.value == vals.layout) { - dlg.set_options("pvs", { - min_selected: lay.min_pvs, - explanation: min_pvs_explanation(vals.pvs, lay.min_pvs) - }); - } - } - dlg.set_options("layout", - { - choices: compute_layout_choices(vals.pvs), - explanation: layout_descriptions[vals.layout] - }); - const max = max_size(vals); - const old_max = dlg.get_options("size").max; - if (vals.size > max || vals.size == old_max) - dlg.set_values({ size: max }); - dlg.set_options("size", { max }); - } else if (trigger == "purpose") { - dlg.set_options("size", { max: vgroup.FreeSize }); - } - }, - Action: { - Title: _("Create"), - action: (vals, progress) => { - if (vals.purpose == "block") { - if (!can_do_layouts) - return vgroup.CreatePlainVolume(vals.name, vals.size, { }); - else { - return vgroup.CreatePlainVolumeWithLayout(vals.name, vals.size, vals.layout, - vals.pvs.map(spc => spc.block.path), - { }); - } - } else if (vals.purpose == "pool") - return vgroup.CreateThinPoolVolume(vals.name, vals.size, { }); - else if (vals.purpose == "vdo") { - return (need_vdo_install ? install_package(vdo_package, progress) : Promise.resolve()) - .then(() => { - progress(_("Creating VDO device")); // not cancellable any more - return vgroup.CreateVDOVolume( - // HACK: emulate lvcreate's automatic pool name creation until - // https://github.com/storaged-project/udisks/issues/939 - vals.name, next_default_logical_volume_name(client, vgroup, "vpool"), - vals.vdo_psize, vals.vdo_lsize, - 0, // default index memory - vals.vdo_options.compression, vals.vdo_options.deduplication, - "auto", { }); - }); - } - } - } - }); -} - -export class VGroup extends React.Component { - constructor () { - super(); - this.on_fsys_samples = () => { this.setState({}) }; - } - - componentDidMount() { - this.props.client.fsys_sizes.addEventListener("changed", this.on_fsys_samples); - } - - componentWillUnmount() { - this.props.client.fsys_sizes.removeEventListener("changed", this.on_fsys_samples); - } - - render() { - const vgroup = this.props.vgroup; - const client = this.props.client; - - let excuse = null; - if (vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0) - excuse = _("New logical volumes can not be created while a volume group is missing physical volumes."); - else if (vgroup.FreeSize == 0) - excuse = _("No free space"); - - const new_volume_link = ( - create_logical_volume(client, vgroup)} - excuse={excuse}> - {_("Create new logical volume")} - - ); - - return ( - - - {_("Logical volumes")} - - - - - - ); - } -} diff --git a/pkg/storaged/crypto-panel.jsx b/pkg/storaged/crypto-panel.jsx deleted file mode 100644 index 8dea27cf16a5..000000000000 --- a/pkg/storaged/crypto-panel.jsx +++ /dev/null @@ -1,88 +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 cockpit from "cockpit"; -import React from "react"; -import { SortByDirection } from '@patternfly/react-table'; - -import { ListingTable } from "cockpit-components-table.jsx"; -import { block_name, get_block_link_parts, go_to_block } from "./utils.js"; -import { OptionalPanel } from "./optional-panel.jsx"; -import { get_fstab_config } from "./fsys-tab.jsx"; - -const _ = cockpit.gettext; - -export class LockedCryptoPanel extends React.Component { - render() { - const client = this.props.client; - - function is_locked_crypto(path) { - const block = client.blocks[path]; - const crypto = client.blocks_crypto[path]; - const cleartext = client.blocks_cleartext[path]; - if (crypto && !cleartext && !block.HintIgnore) { - const [, mount_point] = get_fstab_config(block, true); - return !mount_point; - } - return false; - } - - function make_locked_crypto(path) { - const block = client.blocks[path]; - - const parts = get_block_link_parts(client, block.path); - const name = cockpit.format(parts.format, parts.link); - - return { - props: { path, client, key: path }, - columns: [ - { title: name }, - { title: block_name(block) }, - ] - }; - } - - const locked_cryptos = Object.keys(client.blocks).filter(is_locked_crypto) - .map(make_locked_crypto); - - if (locked_cryptos.length == 0) - return null; - - function onRowClick(event, row) { - if (!event || event.button !== 0) - return; - go_to_block(row.props.client, row.props.path); - } - - return ( - - - - ); - } -} diff --git a/pkg/storaged/crypto-tab.jsx b/pkg/storaged/crypto-tab.jsx deleted file mode 100644 index 6b1aa9c0e004..000000000000 --- a/pkg/storaged/crypto-tab.jsx +++ /dev/null @@ -1,238 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2016 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 { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; -import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; -import cockpit from "cockpit"; -import { dialog_open, TextInput, PassInput } from "./dialog.jsx"; -import { encode_filename, decode_filename, block_name, parse_options, unparse_options, extract_option, edit_crypto_config } from "./utils.js"; -import { is_mounted } from "./fsys-tab.jsx"; - -import React from "react"; -import { StorageLink } from "./storage-controls.jsx"; - -import * as python from "python.js"; -import luksmeta_monitor_hack_py from "./luksmeta-monitor-hack.py"; -import * as timeformat from "timeformat.js"; - -import { CryptoKeyslots } from "./crypto-keyslots.jsx"; - -const _ = cockpit.gettext; - -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; -} - -export class CryptoTab extends React.Component { - constructor() { - super(); - this.state = { - luks_version: null, - slots: null, - slot_error: null, - max_slots: null, - stored_passphrase_mtime: 0, - }; - } - - monitor_slots(block) { - // HACK - we only need this until UDisks2 has a Encrypted.Slots property or similar. - if (block != this.monitored_block) { - if (this.monitored_block) - this.monitor_channel.close(); - this.monitored_block = block; - if (block) { - const dev = decode_filename(block.Device); - this.monitor_channel = python.spawn(luksmeta_monitor_hack_py, [dev], { superuser: true }); - let buf = ""; - this.monitor_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]); - this.setState({ slots: data.slots, luks_version: data.version, max_slots: data.max_slots }); - } - }); - this.monitor_channel.catch(err => { - this.setState({ slots: [], slot_error: err }); - }); - } - } - } - - monitor_path_mtime(path) { - if (path != this.monitored_path) { - if (this.monitored_file) - this.monitored_file.close(); - this.monitored_path = path; - if (path) { - this.monitored_file = cockpit.file(path, { superuser: true }); - this.monitored_file.watch((_, tag) => this.setState({ stored_passphrase_mtime: parse_tag_mtime(tag) }), - { read: false }); - } - } - } - - componentWillUnmount() { - this.monitor_slots(null); - this.monitor_path_mtime(null); - } - - render() { - const self = this; - const client = self.props.client; - const block = self.props.block; - - this.monitor_slots(block); - - 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(); - } - } - }); - }); - } - - 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); - } - - this.monitor_path_mtime(passphrase_path); - - const split_options = parse_options(old_options); - let opt_noauto = extract_option(split_options, "noauto"); - const extra_options = unparse_options(split_options); - - function edit_options() { - const fsys_config = client.blocks_crypto[block.path]?.ChildConfiguration.find(c => c[0] == "fstab"); - const content_block = client.blocks_cleartext[block.path]; - const is_fsys = fsys_config || (content_block && content_block.IdUsage == "filesystem"); - - edit_crypto_config(block, function (config, commit) { - dialog_open({ - Title: _("Encryption options"), - Fields: [ - TextInput("options", "", { value: extra_options }), - ], - isFormHorizontal: false, - Action: { - Title: _("Save"), - action: function (vals) { - let opts = []; - if (is_fsys && content_block) - opt_noauto = !is_mounted(client, content_block); - if (opt_noauto) - opts.push("noauto"); - opts = opts.concat(parse_options(vals.options)); - config.options = { - t: 'ay', - v: encode_filename(unparse_options(opts)) - }; - return commit(); - } - } - }); - }); - } - - const cleartext = client.blocks_cleartext[block.path]; - - const option_parts = []; - if (extra_options) - option_parts.push(extra_options); - const options = option_parts.join(", "); - - return ( -
- - - {_("Encryption type")} - - { this.state.luks_version ? "LUKS" + this.state.luks_version : "-" } - - - - {_("Cleartext device")} - - {cleartext ? block_name(cleartext) : "-"} - - - - {_("Stored passphrase")} - - - { passphrase_path ? this.state.stored_passphrase_mtime || _("yes") : _("none") } - {_("edit")} - - - - - {_("Options")} - - - { options || _("none") } - {_("edit")} - - - - -
- -
- ); - } -} diff --git a/pkg/storaged/crypto/actions.jsx b/pkg/storaged/crypto/actions.jsx new file mode 100644 index 000000000000..f149baf160ae --- /dev/null +++ b/pkg/storaged/crypto/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 "./keyslots.jsx"; +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/clevis-luks-passphrase.sh b/pkg/storaged/crypto/clevis-luks-passphrase.sh similarity index 96% rename from pkg/storaged/clevis-luks-passphrase.sh rename to pkg/storaged/crypto/clevis-luks-passphrase.sh index 964b4f6bed9b..ad41a2ee37fa 100755 --- a/pkg/storaged/clevis-luks-passphrase.sh +++ b/pkg/storaged/crypto/clevis-luks-passphrase.sh @@ -36,7 +36,7 @@ if cryptsetup isLuks --type luks1 "$DEV"; then luksmeta test -d "$DEV" 2>/dev/null || exit 0 luksmeta show -d "$DEV" | while read slot state uuid; do - if [ "$state" = "active" -a "$uuid" = "$CLEVIS_UUID" ]; then + if [ "$state" = "active" ] && [ "$uuid" = "$CLEVIS_UUID" ]; then if pp=$(luksmeta load -d "$DEV" -s "$slot" | clevis decrypt); then if [ "$opt_type" = yes ]; then echo clevis diff --git a/pkg/storaged/crypto/encryption.jsx b/pkg/storaged/crypto/encryption.jsx new file mode 100644 index 000000000000..4245de38131d --- /dev/null +++ b/pkg/storaged/crypto/encryption.jsx @@ -0,0 +1,243 @@ +/* + * 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 { useObject, useEvent } from "hooks"; +import * as python from "python.js"; +import * as timeformat from "timeformat.js"; + +import { dialog_open, TextInput, PassInput } from "../dialog.jsx"; +import { block_name, encode_filename, decode_filename, parse_options, unparse_options, extract_option, edit_crypto_config } from "../utils.js"; +import { StorageCard, StorageDescription, new_card } from "../pages.jsx"; +import luksmeta_monitor_hack_py from "./luksmeta-monitor-hack.py"; +import { is_mounted } from "../filesystem/utils.jsx"; +import { StorageLink } from "../storage-controls.jsx"; +import { CryptoKeyslots } from "./keyslots.jsx"; + +const _ = cockpit.gettext; + +export function make_encryption_card(next, block) { + return new_card({ + title: _("Encryption"), + next, + type_extra: _("encrypted"), + component: EncryptionCard, + 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 EncryptionCard = ({ card, 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) : "-"} + + {_("edit")}} /> + {_("edit")}} /> + + + + ); +}; diff --git a/pkg/storaged/crypto-keyslots.jsx b/pkg/storaged/crypto/keyslots.jsx similarity index 88% rename from pkg/storaged/crypto-keyslots.jsx rename to pkg/storaged/crypto/keyslots.jsx index b2d1e8c3f67a..497f07ec27ac 100644 --- a/pkg/storaged/crypto-keyslots.jsx +++ b/pkg/storaged/crypto/keyslots.jsx @@ -20,90 +20,31 @@ 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 { FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js"; import { DataList, DataListCell, DataListItem, DataListItemCells, DataListItemRow } from "@patternfly/react-core/dist/esm/components/DataList/index.js"; import { Text, TextContent, TextList, TextListItem, TextVariants } from "@patternfly/react-core/dist/esm/components/Text/index.js"; import { TextInput as TextInputPF } from "@patternfly/react-core/dist/esm/components/TextInput/index.js"; import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; import { EditIcon, MinusIcon, PlusIcon } from "@patternfly/react-icons"; +import { EmptyState, EmptyStateBody } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js"; -import sha1 from "js-sha1"; -import sha256 from "js-sha256"; -import stable_stringify from "json-stable-stringify-without-jsonify"; import { check_missing_packages, install_missing_packages, Enum as PkEnum } from "packagekit"; +import { fmt_to_fragments } from "utils.jsx"; import { dialog_open, SelectOneRadio, TextInput, PassInput, Skip -} from "./dialog.jsx"; -import { decode_filename, encode_filename, get_block_mntopts, block_name, for_each_async, get_children, parse_options, unparse_options, edit_crypto_config } from "./utils.js"; -import { fmt_to_fragments } from "utils.jsx"; -import { StorageButton } from "./storage-controls.jsx"; +} from "../dialog.jsx"; +import { decode_filename, encode_filename, get_block_mntopts, block_name, for_each_async, get_children, parse_options, unparse_options, edit_crypto_config } from "../utils.js"; +import { StorageButton } from "../storage-controls.jsx"; import clevis_luks_passphrase_sh from "./clevis-luks-passphrase.sh"; +import { validate_url, get_tang_adv, TangKeyVerification } from "./tang.jsx"; const _ = cockpit.gettext; -/* Tang advertisement utilities - */ - -export function get_tang_adv(url) { - return cockpit.spawn(["curl", "-sSf", url + "/adv"], { err: "message" }) - .then(JSON.parse) - .catch(error => { - return Promise.reject(error.toString().replace(/^curl: \([0-9]+\) /, "")); - }); -} - -function tang_adv_payload(adv) { - return JSON.parse(cockpit.utf8_decoder().decode(cockpit.base64_decode(adv.payload))); -} - -function jwk_b64_encode(bytes) { - // Use the urlsafe character set, and strip the padding. - return cockpit.base64_encode(bytes).replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ''); -} - -function compute_thp(jwk) { - const REQUIRED_ATTRS = { - RSA: ['kty', 'p', 'd', 'q', 'dp', 'dq', 'qi', 'oth'], - EC: ['kty', 'crv', 'x', 'y'], - oct: ['kty', 'k'], - }; - - if (!jwk.kty) - return "(no key type attribute="; - if (!REQUIRED_ATTRS[jwk.kty]) - return cockpit.format("(unknown keytype $0)", jwk.kty); - - const req = REQUIRED_ATTRS[jwk.kty]; - const norm = { }; - req.forEach(k => { if (k in jwk) norm[k] = jwk[k]; }); - return { - sha256: jwk_b64_encode(sha256.digest(stable_stringify(norm))), - sha1: jwk_b64_encode(sha1.digest(stable_stringify(norm))) - }; -} - -function compute_sigkey_thps(adv) { - function is_signing_key(jwk) { - if (!jwk.use && !jwk.key_ops) - return true; - if (jwk.use == "sig") - return true; - if (jwk.key_ops && jwk.key_ops.indexOf("verify") >= 0) - return true; - return false; - } - - return adv.keys.filter(is_signing_key).map(compute_thp); -} - /* Clevis operations */ @@ -472,26 +413,6 @@ function ensure_nbde_support_dialog(steps, client, block, url, adv, old_key, exi }); } -function parse_url(url) { - // clevis-encrypt-tang defaults to "http://" (via curl), so we do the same here. - if (!/^[a-zA-Z]+:\/\//.test(url)) - url = "http://" + url; - try { - return new URL(url); - } catch (e) { - if (e instanceof TypeError) - return null; - throw e; - } -} - -export function validate_url(url) { - if (url.length === 0) - return _("Address cannot be empty"); - if (!parse_url(url)) - return _("Address is not a valid URL"); -} - function add_dialog(client, block) { let recovered_passphrase; @@ -612,35 +533,6 @@ function add_or_update_tang(dlg, vals, block, url, adv, old_key, passphrase) { .catch(request_passphrase_on_error_handler(dlg, vals, passphrase, block)); } -export const TangKeyVerification = ({ url, adv }) => { - const parsed = parse_url(url); - const cmd = cockpit.format("ssh $0 tang-show-keys $1", parsed.hostname, parsed.port); - const sigkey_thps = compute_sigkey_thps(tang_adv_payload(adv)); - - return ( - - {_("Check the key hash with the Tang server.")} - - {_("How to check")} - {_("In a terminal, run: ")} - - {cmd} - - - {_("Check that the SHA-256 or SHA-1 hash from the command matches this dialog.")} - - - {_("SHA-256")} - { sigkey_thps.map(s => {s.sha256}) } - - {_("SHA-1")} - { sigkey_thps.map(s => {s.sha1}) } - ); -}; - function edit_tang_adv(client, block, key, url, adv, passphrase) { const dlg = dialog_open({ Title: _("Verify key"), @@ -794,7 +686,7 @@ export class CryptoKeyslots extends React.Component { const keys = slots ? slots.map(decode_clevis_slot).filter(k => !!k) : []; - let rows; + let table; if (keys.length == 0) { let text; if (slot_error) { @@ -805,9 +697,13 @@ export class CryptoKeyslots extends React.Component { } else { text = _("No keys added"); } - rows = {text}; + table = + + {text} + + ; } else { - rows = []; + const rows = []; const add_row = (slot, type, desc, edit, edit_excuse, remove) => { rows.push( @@ -864,12 +760,16 @@ export class CryptoKeyslots extends React.Component { () => remove_clevis_dialog(client, block, key)); } }); + + table = + {rows} + ; } const remaining = max_slots - keys.length; return ( - + <> @@ -886,12 +786,10 @@ export class CryptoKeyslots extends React.Component { }}> {_("Keys")} - - - {rows} - + + {table} - + ); } } diff --git a/pkg/storaged/crypto/locked-encrypted-data.jsx b/pkg/storaged/crypto/locked-encrypted-data.jsx new file mode 100644 index 000000000000..155aade4e7d0 --- /dev/null +++ b/pkg/storaged/crypto/locked-encrypted-data.jsx @@ -0,0 +1,41 @@ +/* + * 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 { StorageCard, new_card } from "../pages.jsx"; +import { format_dialog } from "../block/format-dialog.jsx"; +import { unlock } from "./actions.jsx"; + +const _ = cockpit.gettext; + +export function make_locked_encrypted_data_card(next, block) { + return new_card({ + title: _("Locked data"), + next, + page_block: block, + component: StorageCard, + props: { block }, + actions: [ + { title: _("Unlock"), action: () => unlock(block) }, + { title: _("Format"), action: () => format_dialog(client, block.path), danger: true }, + ] + }); +} diff --git a/pkg/storaged/luksmeta-monitor-hack.py b/pkg/storaged/crypto/luksmeta-monitor-hack.py similarity index 100% rename from pkg/storaged/luksmeta-monitor-hack.py rename to pkg/storaged/crypto/luksmeta-monitor-hack.py diff --git a/pkg/storaged/crypto/tang.jsx b/pkg/storaged/crypto/tang.jsx new file mode 100644 index 000000000000..1ba65a33cfda --- /dev/null +++ b/pkg/storaged/crypto/tang.jsx @@ -0,0 +1,133 @@ +/* + * 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 { ClipboardCopy } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js"; +import { Text, TextContent, TextVariants } from "@patternfly/react-core/dist/esm/components/Text/index.js"; + +import sha1 from "js-sha1"; +import sha256 from "js-sha256"; +import stable_stringify from "json-stable-stringify-without-jsonify"; + +const _ = cockpit.gettext; + +export function validate_url(url) { + if (url.length === 0) + return _("Address cannot be empty"); + if (!parse_url(url)) + return _("Address is not a valid URL"); +} + +export function get_tang_adv(url) { + return cockpit.spawn(["curl", "-sSf", url + "/adv"], { err: "message" }) + .then(JSON.parse) + .catch(error => { + return Promise.reject(error.toString().replace(/^curl: \([0-9]+\) /, "")); + }); +} + +function parse_url(url) { + // clevis-encrypt-tang defaults to "http://" (via curl), so we do the same here. + if (!/^[a-zA-Z]+:\/\//.test(url)) + url = "http://" + url; + try { + return new URL(url); + } catch (e) { + if (e instanceof TypeError) + return null; + throw e; + } +} + +function tang_adv_payload(adv) { + return JSON.parse(cockpit.utf8_decoder().decode(cockpit.base64_decode(adv.payload))); +} + +function jwk_b64_encode(bytes) { + // Use the urlsafe character set, and strip the padding. + return cockpit.base64_encode(bytes).replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ''); +} + +function compute_thp(jwk) { + const REQUIRED_ATTRS = { + RSA: ['kty', 'p', 'd', 'q', 'dp', 'dq', 'qi', 'oth'], + EC: ['kty', 'crv', 'x', 'y'], + oct: ['kty', 'k'], + }; + + if (!jwk.kty) + return "(no key type attribute="; + if (!REQUIRED_ATTRS[jwk.kty]) + return cockpit.format("(unknown keytype $0)", jwk.kty); + + const req = REQUIRED_ATTRS[jwk.kty]; + const norm = { }; + req.forEach(k => { if (k in jwk) norm[k] = jwk[k]; }); + return { + sha256: jwk_b64_encode(sha256.digest(stable_stringify(norm))), + sha1: jwk_b64_encode(sha1.digest(stable_stringify(norm))) + }; +} + +function compute_sigkey_thps(adv) { + function is_signing_key(jwk) { + if (!jwk.use && !jwk.key_ops) + return true; + if (jwk.use == "sig") + return true; + if (jwk.key_ops && jwk.key_ops.indexOf("verify") >= 0) + return true; + return false; + } + + return adv.keys.filter(is_signing_key).map(compute_thp); +} + +export const TangKeyVerification = ({ url, adv }) => { + const parsed = parse_url(url); + const cmd = cockpit.format("ssh $0 tang-show-keys $1", parsed.hostname, parsed.port); + const sigkey_thps = compute_sigkey_thps(tang_adv_payload(adv)); + + return ( + + {_("Check the key hash with the Tang server.")} + + {_("How to check")} + {_("In a terminal, run: ")} + + {cmd} + + + {_("Check that the SHA-256 or SHA-1 hash from the command matches this dialog.")} + + + {_("SHA-256")} + { sigkey_thps.map(s => {s.sha256}) } + + {_("SHA-1")} + { sigkey_thps.map(s => {s.sha1}) } + ); +}; diff --git a/pkg/storaged/details.jsx b/pkg/storaged/details.jsx deleted file mode 100644 index a9723443afd9..000000000000 --- a/pkg/storaged/details.jsx +++ /dev/null @@ -1,151 +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 cockpit from "cockpit"; -import React from "react"; - -import { Card } from '@patternfly/react-core/dist/esm/components/Card/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 { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; -import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; - -import * as utils from "./utils.js"; -import { BlockDetails } from "./block-details.jsx"; -import { DriveDetails } from "./drive-details.jsx"; -import { VGroupDetails } from "./vgroup-details.jsx"; -import { MDRaidDetails } from "./mdraid-details.jsx"; -import { VDODetails } from "./vdo-details.jsx"; -import { NFSDetails } from "./nfs-details.jsx"; -import { StratisPoolDetails, StratisStoppedPoolDetails } from "./stratis-details.jsx"; -import { JobsPanel } from "./jobs-panel.jsx"; - -const _ = cockpit.gettext; - -export const StdDetailsLayout = ({ client, alerts, header, content, sidebar }) => { - const top = <> - { (alerts || []).filter(a => !!a).map((a, i) => {a}) } - - { header } - - ; - - if (sidebar) { - return ( - <> - { top } - - -
- { content } - -
-
- - { sidebar } - -
- - ); - } else { - return ( - <> - { top } - -
- { content } -
- -
- - ); - } -}; - -export class Details extends React.Component { - render() { - const client = this.props.client; - - let body = null; - let name = this.props.name; - if (this.props.type == "block") { - const block = client.slashdevs_block["/dev/" + this.props.name]; - const drive = block && client.drives[block.Drive]; - - if (drive) { - name = utils.drive_name(drive); - body = ; - } else if (block) { - name = utils.block_name(block); - body = ; - } - } else if (this.props.type == "vg") { - const vgroup = client.vgnames_vgroup[this.props.name]; - if (vgroup) { - name = vgroup.Name; - body = ; - } - } else if (this.props.type == "mdraid") { - const mdraid = client.uuids_mdraid[this.props.name]; - if (mdraid) { - name = utils.mdraid_name(mdraid); - body = ; - } - } else if (this.props.type == "vdo") { - const vdo = client.legacy_vdo_overlay.by_name[this.props.name]; - if (vdo) { - name = vdo.name; - body = ; - } - } else if (this.props.type == "nfs") { - const entry = client.nfs.find_entry(name, this.props.name2); - if (entry) - body = ; - } else if (this.props.type == "pool") { - const pool = (client.stratis_poolnames_pool[this.props.name] || - client.stratis_pooluuids_pool[this.props.name]); - const stopped_props = client.stratis_manager.StoppedPools[this.props.name]; - - if (pool) - body = ; - else if (stopped_props) - body = ; - } - - if (!body) - body = _("Not found"); - - return ( - - - - {_("Storage")} - {name} - - - - - {body} - - - - ); - } -} diff --git a/pkg/storaged/dialog.jsx b/pkg/storaged/dialog.jsx index dc25f296a97a..97b0d9f82ff4 100644 --- a/pkg/storaged/dialog.jsx +++ b/pkg/storaged/dialog.jsx @@ -1104,7 +1104,7 @@ 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 MDRAID device"), vdo: _("backing device for VDO device"), "stratis-pool-member": _("member of Stratis pool") }; diff --git a/pkg/storaged/drive-details.jsx b/pkg/storaged/drive-details.jsx deleted file mode 100644 index e6527398917b..000000000000 --- a/pkg/storaged/drive-details.jsx +++ /dev/null @@ -1,97 +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 cockpit from "cockpit"; -import React from "react"; - -import { Card, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js"; -import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; -import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; - -import * as utils from "./utils.js"; -import { StdDetailsLayout } from "./details.jsx"; -import { Block } from "./content-views.jsx"; - -const _ = cockpit.gettext; - -export class DriveDetails extends React.Component { - render() { - const client = this.props.client; - const drive = this.props.drive; - const drive_ata = client.drives_ata[drive.path]; - const drive_block = drive && client.drives_block[drive.path]; - const multipath_blocks = drive && client.drives_multipath_blocks[drive.path]; - - const DriveDetailsRow = ({ title, value }) => { - if (!value) - return null; - return ( - - {title} - {value} - - ); - }; - - let assessment = null; - if (drive_ata) { - assessment = ( - - {_("storage", "Assessment")} - - - { drive_ata.SmartFailing - ? {_("Disk is failing")} - : {_("Disk is OK")} - } - { drive_ata.SmartTemperature > 0 - ? ({utils.format_temperature(drive_ata.SmartTemperature)}) - : null - } - - - - ); - } - - const header = ( - - {_("Drive")} - - - - - - - - { assessment } - - {multipath_blocks.length > 0 && ( - - )} - - - - ); - - const content = ; - - return ; - } -} diff --git a/pkg/storaged/drive/drive.jsx b/pkg/storaged/drive/drive.jsx new file mode 100644 index 000000000000..60320fb15b7f --- /dev/null +++ b/pkg/storaged/drive/drive.jsx @@ -0,0 +1,139 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; + +import { HDDIcon, SSDIcon, MediaDriveIcon } from "../icons/gnome-icons.jsx"; +import { StorageCard, StorageDescription, new_card, new_page } from "../pages.jsx"; +import { block_name, drive_name, format_temperature, fmt_size_long } from "../utils.js"; +import { make_block_page } from "../block/create-pages.jsx"; +import { partitionable_block_actions } from "../partitions/actions.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; + + let cls; + if (client.drives_iscsi_session[drive.path]) + cls = "iscsi"; + else if (drive.MediaRemovable || drive.Media) + cls = "media"; + else + cls = (drive.RotationRate === 0) ? "ssd" : "hdd"; + + const drive_title = { + media: _("Media drive"), + ssd: _("Solid State Drive"), + hdd: _("Hard Disk Drive"), + iscsi: _("iSCSI Drive"), + }; + + const drive_icon = { + media: MediaDriveIcon, + ssd: SSDIcon, + hdd: HDDIcon, + }; + + const drive_card = new_card({ + title: drive_title[cls] || _("Drive"), + next: null, + page_block: block, + page_icon: drive_icon[cls], + for_summary: true, + id_extra: drive_name(drive), + job_path: drive.path, + component: DriveCard, + props: { drive }, + actions: block.Size > 0 ? partitionable_block_actions(block) : [], + }); + + if (block.Size > 0) { + make_block_page(parent, block, drive_card); + } else { + new_page(parent, drive_card); + } +} + +const DriveCard = ({ card, 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]; + + 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 + } + + ); + } + + return ( + + + + + + + + + + {drive.Size + ? fmt_size_long(drive.Size) + : _("No media inserted") + } + + { assessment } + + { multipath_blocks.length > 0 && + + } + + + + ); +}; diff --git a/pkg/storaged/drives-panel.jsx b/pkg/storaged/drives-panel.jsx deleted file mode 100644 index d60a887a739b..000000000000 --- a/pkg/storaged/drives-panel.jsx +++ /dev/null @@ -1,116 +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 cockpit from "cockpit"; -import React from "react"; - -import { SidePanel } from "./side-panel.jsx"; -import { fmt_size, drive_name, decode_filename, block_name } from "./utils.js"; - -const _ = cockpit.gettext; -const C_ = cockpit.gettext; - -export function drive_rows(client) { - function cmp_drive(path_a, path_b) { - return client.drives[path_a].SortKey.localeCompare(client.drives[path_b].SortKey); - } - - function classify_drive(drive) { - if (drive.MediaRemovable || drive.Media) { - for (let i = 0; i < drive.MediaCompatibility.length; i++) - if (drive.MediaCompatibility[i].indexOf("optical") === 0) - return "optical"; - return "removable"; - } - - return (drive.RotationRate === 0) ? "ssd" : "hdd"; - } - - function make_drive(path) { - const drive = client.drives[path]; - let block = client.drives_block[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[path][0]; - } - - if (!block) - return null; - - const dev = decode_filename(block.Device).replace(/^\/dev\//, ""); - - const name = drive_name(drive); - const classification = classify_drive(drive); - const size_str = fmt_size(drive.Size); - let type, desc; - if (classification == "removable") { - type = C_("storage", "Removable drive"); - if (drive.Size === 0) - desc = type; - else - desc = size_str + " " + type; - } else if (classification == "optical") { - type = C_("storage", "Optical drive"); - desc = type; - } else { - type = C_("Drive"); - if (drive.Size === 0) - desc = type; - else - desc = size_str; - } - - return { - client, - name, - devname: block_name(block), - size: drive.Size, - type, - detail: desc, - go: () => cockpit.location.go([dev]), - block: drive && client.drives_block[path], - job_path: path, - key: path - }; - } - - return Object.keys(client.drives).sort(cmp_drive).map(make_drive); -} - -export class DrivesPanel extends React.Component { - render() { - const props = this.props; - const client = props.client; - - const drives = drive_rows(client); - - return ( - - ); - } -} diff --git a/pkg/storaged/filesystem/filesystem.jsx b/pkg/storaged/filesystem/filesystem.jsx new file mode 100644 index 000000000000..fd119e3f95a4 --- /dev/null +++ b/pkg/storaged/filesystem/filesystem.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 React from "react"; +import client from "../client"; + +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { useEvent } from "hooks"; + +import { + block_name, fmt_size, validate_fsys_label +} from "../utils.js"; +import { + dialog_open, TextInput, +} from "../dialog.jsx"; +import { StorageLink, StorageUsageBar, StorageSize } from "../storage-controls.jsx"; +import { StorageCard, StorageDescription, new_card, useIsNarrow } from "../pages.jsx"; + +import { format_dialog } from "../block/format-dialog.jsx"; +import { is_mounted, MountPoint } from "./utils.jsx"; +import { mounting_dialog } from "./mounting-dialog.jsx"; +import { check_mismounted_fsys, MismountAlert } from "./mismounting.jsx"; + +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. + */ + +const MountPointUsageBar = ({ mount_point, block, short }) => { + useEvent(client.fsys_sizes, "changed"); + const narrow = useIsNarrow(); + + const stats = client.fsys_sizes.data[mount_point]; + if (stats) + return ; + else if (short && !narrow) + return ; + else + return fmt_size(block.Size); +}; + +export function make_filesystem_card(next, backing_block, content_block, fstab_config) { + const [, mount_point] = fstab_config; + 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)"); + + return new_card({ + title: content_block ? cockpit.format(_("$0 filesystem"), content_block.IdType) : _("Filesystem"), + location: mp_text, + next, + page_size: , + has_warning: !!mismount_warning, + component: FilesystemCard, + 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 FilesystemCard = ({ + card, 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, {}); + } + } + }); + } + + const [, mount_point] = fstab_config; + const mounted = content_block && is_mounted(client, content_block); + + return ( + + + + + {_("edit")} + } /> + + + + { mounted && + + + + } + + + { mismount_warning && + + + + } + ); +}; diff --git a/pkg/storaged/filesystem/mismounting.jsx b/pkg/storaged/filesystem/mismounting.jsx new file mode 100644 index 000000000000..3d9a5bbd6b88 --- /dev/null +++ b/pkg/storaged/filesystem/mismounting.jsx @@ -0,0 +1,225 @@ +/* + * 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.js"; + +import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; + +import { + decode_filename, encode_filename, + parse_options, unparse_options, extract_option, reload_systemd, + set_crypto_auto_option, +} from "../utils.js"; +import { StorageButton } from "../storage-controls.jsx"; + +import { mounting_dialog } from "./mounting-dialog.jsx"; +import { get_cryptobacking_noauto } from "./utils.jsx"; + +const _ = cockpit.gettext; + +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] }; +} + +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} } +
+
); +}; diff --git a/pkg/storaged/filesystem/mounting-dialog.jsx b/pkg/storaged/filesystem/mounting-dialog.jsx new file mode 100644 index 000000000000..6b01fb561806 --- /dev/null +++ b/pkg/storaged/filesystem/mounting-dialog.jsx @@ -0,0 +1,340 @@ +/* + * 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 { + encode_filename, + parse_options, unparse_options, extract_option, reload_systemd, + set_crypto_options, is_mounted_synch, + get_active_usage, teardown_active_usage, +} from "../utils.js"; + +import { + dialog_open, + TextInput, PassInput, CheckBoxes, SelectOne, + TeardownMessage, + init_active_usage_processes +} from "../dialog.jsx"; +import { init_existing_passphrase, unlock_with_type } from "../crypto/keyslots.jsx"; +import { initial_tab_options, mount_explanation } from "../block/format-dialog.jsx"; + +import { + is_mounted, get_fstab_config, + is_valid_mount_point +} from "./utils.jsx"; + +const _ = cockpit.gettext; + +export function mounting_dialog(client, block, mode, forced_options) { + const block_fsys = client.blocks_fsys[block.path]; + const [old_config, old_dir, old_opts, old_parents] = get_fstab_config(block, true); + const options = old_config ? old_opts : initial_tab_options(client, block, true); + + const split_options = parse_options(options); + extract_option(split_options, "noauto"); + const opt_never_auto = extract_option(split_options, "x-cockpit-never-auto"); + const opt_ro = extract_option(split_options, "ro"); + 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); + const extra_options = unparse_options(split_options); + + const is_filesystem_mounted = is_mounted(client, block); + + function maybe_update_config(new_dir, new_opts, passphrase, passphrase_type) { + let new_config = null; + 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; + + if (new_dir != "") { + if (new_dir[0] != "/") + new_dir = "/" + new_dir; + 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 undo() { + if (!old_config && new_config) + return block.RemoveConfigurationItem(new_config, {}); + else if (old_config && !new_config) + return block.AddConfigurationItem(old_config, {}); + else if (old_config && new_config && (new_dir != old_dir || new_opts != old_opts)) { + return block.UpdateConfigurationItem(new_config, old_config, {}); + } + } + + function get_block_fsys() { + if (block_fsys) + return Promise.resolve(block_fsys); + else + return client.wait_for(() => (client.blocks_cleartext[block.path] && + client.blocks_fsys[client.blocks_cleartext[block.path].path])); + } + + function maybe_mount() { + if (mode == "mount" || (mode == "update" && is_filesystem_mounted)) { + return (get_block_fsys() + .then(block_fsys => { + const block = client.blocks[block_fsys.path]; + return (client.mount_at(block, new_dir) + .catch(error => { + // systemd might have mounted the filesystem for us after + // unlocking, because fstab told it to. Ignore any error + // from mounting in that case. This only happens when this + // code runs to fix a inconsistent mount. + return (is_mounted_synch(block) + .then(mounted_at => { + if (mounted_at == new_dir) + return; + return (undo() + .then(() => { + if (is_filesystem_mounted) + return client.mount_at(block, old_dir); + }) + .catch(ignored_error => { + console.warn("Error during undo:", ignored_error); + }) + .then(() => Promise.reject(error))); + })); + })); + })); + } else + return Promise.resolve(); + } + + function maybe_unlock() { + const crypto = client.blocks_crypto[block.path]; + if (mode == "mount" && crypto) { + return (unlock_with_type(client, block, passphrase, passphrase_type) + .catch(error => { + dlg.set_values({ needs_explicit_passphrase: true }); + return Promise.reject(error); + })); + } else + return Promise.resolve(); + } + + function maybe_lock() { + if (mode == "unmount") { + const crypto_backing = client.blocks[block.CryptoBackingDevice]; + const crypto_backing_crypto = crypto_backing && client.blocks_crypto[crypto_backing.path]; + if (crypto_backing_crypto) { + return crypto_backing_crypto.Lock({}); + } else + return Promise.resolve(); + } + } + + // We need to reload systemd twice: Once at the beginning so + // that it is up to date with whatever is currently in fstab, + // and once at the end to make it see our changes. Otherwise + // systemd might do some uexpected mounts/unmounts behind our + // backs. + + return (reload_systemd() + .then(() => teardown_active_usage(client, usage)) + .then(maybe_unlock) + .then(() => { + if (!old_config && new_config) + return (block.AddConfigurationItem(new_config, {}) + .then(maybe_mount)); + else if (old_config && !new_config) + return block.RemoveConfigurationItem(old_config, {}); + else if (old_config && new_config) + return (block.UpdateConfigurationItem(old_config, new_config, {}) + .then(maybe_mount)); + else if (new_config && !is_mounted(client, block)) + return maybe_mount(); + }) + .then(maybe_lock) + .then(reload_systemd)); + } + + let at_boot; + if (opt_never_auto) + at_boot = "never"; + else if (opt_netdev) + at_boot = "netdev"; + else if (opt_nofail) + at_boot = "nofail"; + else + at_boot = "local"; + + let fields = null; + if (mode == "mount" || mode == "update") { + fields = [ + TextInput("mount_point", _("Mount point"), + { + value: old_dir, + validate: val => is_valid_mount_point(client, block, val, mode == "update" && !is_filesystem_mounted, true) + }), + CheckBoxes("mount_options", _("Mount options"), + { + value: { + ro: opt_ro, + extra: extra_options || false + }, + fields: [ + { title: _("Mount read only"), tag: "ro" }, + { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" }, + ] + }), + SelectOne("at_boot", _("At boot"), + { + value: at_boot, + explanation: mount_explanation[at_boot], + 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"), + }, + ] + }), + ]; + + if (block.IdUsage == "crypto" && mode == "mount") + fields = fields.concat([ + PassInput("passphrase", _("Passphrase"), + { + visible: vals => vals.needs_explicit_passphrase, + validate: val => !val.length && _("Passphrase cannot be empty"), + }) + ]); + } + + const mode_title = { + mount: _("Mount filesystem"), + unmount: _("Unmount filesystem $0"), + update: _("Mount configuration") + }; + + const mode_action = { + mount: _("Mount"), + unmount: _("Unmount"), + update: _("Save") + }; + + function do_unmount() { + let opts = []; + opts.push("noauto"); + if (opt_ro) + opts.push("ro"); + if (opt_never_auto) + opts.push("x-cockpit-never-auto"); + if (opt_nofail) + opts.push("nofail"); + if (opt_netdev) + opts.push("_netdev"); + if (forced_options) + opts = opts.concat(forced_options); + if (extra_options) + opts = opts.concat(extra_options); + return (maybe_set_crypto_options(null, false, null, null) + .then(() => maybe_update_config(old_dir, unparse_options(opts)))); + } + + let passphrase_type; + + function maybe_set_crypto_options(readonly, auto, nofail, netdev) { + if (client.blocks_crypto[block.path]) { + return set_crypto_options(block, readonly, auto, nofail, netdev); + } else if (client.blocks_crypto[block.CryptoBackingDevice]) { + return set_crypto_options(client.blocks[block.CryptoBackingDevice], readonly, auto, nofail, netdev); + } else + return Promise.resolve(); + } + + const usage = get_active_usage(client, block.path); + + const dlg = dialog_open({ + Title: cockpit.format(mode_title[mode], old_dir), + Fields: fields, + Teardown: TeardownMessage(usage, old_dir), + update: function (dlg, vals, trigger) { + if (trigger == "at_boot") + dlg.set_options("at_boot", { explanation: mount_explanation[vals.at_boot] }); + }, + Action: { + Title: mode_action[mode], + disable_on_error: usage.Teardown, + action: function (vals) { + if (mode == "unmount") { + return do_unmount(); + } else if (mode == "mount" || mode == "update") { + let opts = []; + if ((mode == "update" && !is_filesystem_mounted) || vals.at_boot == "never") + opts.push("noauto"); + if (vals.mount_options.ro) + opts.push("ro"); + if (vals.at_boot == "never") + opts.push("x-cockpit-never-auto"); + if (vals.at_boot == "nofail") + opts.push("nofail"); + if (vals.at_boot == "netdev") + opts.push("_netdev"); + if (forced_options) + opts = opts.concat(forced_options); + if (vals.mount_options.extra !== false) + opts = opts.concat(parse_options(vals.mount_options.extra)); + return (maybe_update_config(vals.mount_point, unparse_options(opts), + vals.passphrase, passphrase_type) + .then(() => maybe_set_crypto_options(vals.mount_options.ro, + opts.indexOf("noauto") == -1, + vals.at_boot == "nofail", + vals.at_boot == "netdev"))); + } + } + }, + Inits: [ + init_active_usage_processes(client, usage, old_dir), + (block.IdUsage == "crypto" && mode == "mount") + ? init_existing_passphrase(block, true, type => { passphrase_type = type }) + : null + ] + }); +} diff --git a/pkg/storaged/filesystem/utils.jsx b/pkg/storaged/filesystem/utils.jsx new file mode 100644 index 000000000000..65fabfb75b98 --- /dev/null +++ b/pkg/storaged/filesystem/utils.jsx @@ -0,0 +1,178 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2016 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 { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; + +import cockpit from "cockpit"; +import client from "../client.js"; + +import { + block_name, decode_filename, + parse_options, extract_option, + get_fstab_config_with_client, + find_children_for_mount_point, +} from "../utils.js"; +import { StorageLink } from "../storage-controls.jsx"; + +import { mounting_dialog } from "./mounting-dialog.jsx"; + +const _ = cockpit.gettext; + +export function is_mounted(client, block) { + const block_fsys = client.blocks_fsys[block.path]; + const mounted_at = block_fsys ? block_fsys.MountPoints : []; + const config = block.Configuration.find(c => c[0] == "fstab"); + if (config && config[1].dir.v) { + let dir = decode_filename(config[1].dir.v); + if (dir[0] != "/") + dir = "/" + dir; + return mounted_at.map(decode_filename).indexOf(dir) >= 0; + } else + return null; +} + +export function get_fstab_config(block, also_child_config) { + return get_fstab_config_with_client(client, block, also_child_config); +} + +function find_blocks_for_mount_point(client, mount_point, self) { + const blocks = []; + + function is_self(b) { + return self && (b == self || client.blocks[b.CryptoBackingDevice] == self); + } + + for (const p in client.blocks) { + const b = client.blocks[p]; + const [, dir] = get_fstab_config(b); + if (dir == mount_point && !is_self(b)) + blocks.push(b); + } + + return blocks; +} + +function nice_block_name(block) { + return block_name(client.blocks[block.CryptoBackingDevice] || block); +} + +export function is_valid_mount_point(client, block, val, format_only, for_fstab) { + if (val === "") { + if (!format_only || for_fstab) + return _("Mount point cannot be empty"); + return null; + } + + const other_blocks = find_blocks_for_mount_point(client, val, block); + if (other_blocks.length > 0) + return cockpit.format(_("Mount point is already used for $0"), + other_blocks.map(nice_block_name).join(", ")); + + if (!format_only) { + const children = find_children_for_mount_point(client, val, block); + if (Object.keys(children).length > 0) + return <> + {_("Filesystems are already mounted below this mountpoint.")} + {Object.keys(children).map(m =>
{cockpit.format("• $0 on $1", nice_block_name(children[m]), m)}
)} + {_("Please unmount them first.")} + ; + } +} + +export function get_cryptobacking_noauto(client, block) { + const crypto_backing = block.IdUsage == "crypto" ? block : client.blocks[block.CryptoBackingDevice]; + if (!crypto_backing) + return false; + + const crypto_config = crypto_backing.Configuration.find(c => c[0] == "crypttab"); + if (!crypto_config) + return false; + + const crypto_options = decode_filename(crypto_config[1].options.v).split(",") + .map(o => o.trim()); + return crypto_options.indexOf("noauto") >= 0; +} + +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 } + ); +}; diff --git a/pkg/storaged/fsys-panel.jsx b/pkg/storaged/fsys-panel.jsx deleted file mode 100644 index fad062456c6c..000000000000 --- a/pkg/storaged/fsys-panel.jsx +++ /dev/null @@ -1,190 +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 cockpit from "cockpit"; -import React from "react"; -import { SortByDirection } from '@patternfly/react-table'; - -import { ListingTable } from "cockpit-components-table.jsx"; -import { StorageUsageBar } from "./storage-controls.jsx"; -import { block_name, fmt_size, go_to_block, flatten, is_snap } from "./utils.js"; -import { OptionalPanel } from "./optional-panel.jsx"; -import { get_fstab_config } from "./fsys-tab.jsx"; - -const _ = cockpit.gettext; - -export class FilesystemsPanel extends React.Component { - constructor () { - super(); - this.on_fsys_samples = () => { this.setState({}) }; - } - - componentDidMount() { - this.props.client.fsys_sizes.addEventListener("changed", this.on_fsys_samples); - } - - componentWillUnmount() { - this.props.client.fsys_sizes.removeEventListener("changed", this.on_fsys_samples); - } - - render() { - const client = this.props.client; - - function is_mount(path) { - const block = client.blocks[path]; - - // Stratis filesystems are handled separately - if (client.blocks_stratis_fsys[path]) - return false; - - if (block.HintIgnore) - return false; - - if (is_snap(client, block)) - return false; - - if (block.IdUsage == "filesystem" && block.IdType != "mpath_member") - return true; - - if (block.IdUsage == "crypto" && !client.blocks_cleartext[block.path]) { - const [, mount_point] = get_fstab_config(block, true); - return !!mount_point; - } - - return false; - } - - function make_mount(path) { - const block = client.blocks[path]; - const [, mount_point] = get_fstab_config(block, true); - const fsys_size = client.fsys_sizes.data[mount_point]; - const backing_block = client.blocks[block.CryptoBackingDevice] || block; - const block_lvm2 = client.blocks_lvm2[backing_block.path]; - const lvol = block_lvm2 && client.lvols[block_lvm2.LogicalVolume]; - const vgroup = lvol && client.vgroups[lvol.VolumeGroup]; - let name = null; - - if (vgroup) - name = vgroup.Name + "/" + lvol.Name; - - if (!name) - name = block_name(backing_block || block); - - if (block.IdLabel) - name = name + " (" + block.IdLabel + ")"; - - return { - props: { path, client, key: path }, - columns: [ - { title: name }, - { title: block.IdType }, - { title: mount_point || "-" }, - { - title: fsys_size - ? - : fmt_size(block.Size), - props: { className: "pf-v5-u-text-align-right" } - } - ] - }; - } - - const mounts = Object.keys(client.blocks).filter(is_mount) - .map(make_mount); - - function has_filesystems(path) { - return client.stratis_pool_filesystems[path].length > 0; - } - - function make_pool(path) { - const pool = client.stratis_pools[path]; - const filesystems = client.stratis_pool_filesystems[path].sort((a, b) => a.Devnode.localeCompare(b.Devnode)); - - const offsets = []; - let total = 0; - filesystems.forEach(fs => { - offsets.push(total); - if (fs.Used[0]) - total += Number(fs.Used[1]); - }); - - const overhead = pool.TotalPhysicalUsed[0] ? (Number(pool.TotalPhysicalUsed[1]) - total) : 0; - const pool_total = Number(pool.TotalPhysicalSize) - overhead; - - return filesystems.map((fs, i) => { - const block = client.slashdevs_block[fs.Devnode]; - let mount = "-"; - if (block) { - const [, mp] = get_fstab_config(block, true); - if (mp) - mount = mp; - } - return { - props: { path, client, key: fs.path }, - columns: [ - { title: pool.Name + "/" + fs.Name }, - { title: "Stratis" }, - { title: mount }, - { - title: (pool.Overprovisioning - ? - : - ), - props: { className: "pf-v5-u-text-align-right" } - } - ] - }; - }); - } - - const pools = Object.keys(client.stratis_pools).filter(has_filesystems) - .map(make_pool); - - function onRowClick(event, row) { - if (!event || event.button !== 0) - return; - - const stratis_pool = row.props.client.stratis_pools[row.props.path]; - if (stratis_pool) { - cockpit.location.go(["pool", stratis_pool.Name]); - } else - go_to_block(row.props.client, row.props.path); - } - - return ( - - - - ); - } -} diff --git a/pkg/storaged/fsys-tab.jsx b/pkg/storaged/fsys-tab.jsx deleted file mode 100644 index 2638e4650c7f..000000000000 --- a/pkg/storaged/fsys-tab.jsx +++ /dev/null @@ -1,736 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2016 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 { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; -import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; -import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; - -import cockpit from "cockpit"; -import * as utils from "./utils.js"; -import { parse_options, unparse_options, extract_option, set_crypto_options, set_crypto_auto_option, get_fstab_config_with_client } from "./utils.js"; - -import { - dialog_open, TextInput, PassInput, CheckBoxes, SelectOne, - TeardownMessage, init_active_usage_processes -} from "./dialog.jsx"; -import { StorageButton, StorageLink } from "./storage-controls.jsx"; -import { initial_tab_options, mount_explanation } from "./format-dialog.jsx"; -import { init_existing_passphrase, unlock_with_type } from "./crypto-keyslots.jsx"; - -import client from "./client.js"; - -const _ = cockpit.gettext; - -export function is_mounted(client, block) { - const block_fsys = client.blocks_fsys[block.path]; - const mounted_at = block_fsys ? block_fsys.MountPoints : []; - const config = block.Configuration.find(c => c[0] == "fstab"); - if (config && config[1].dir.v) { - let dir = utils.decode_filename(config[1].dir.v); - if (dir[0] != "/") - dir = "/" + dir; - return mounted_at.map(utils.decode_filename).indexOf(dir) >= 0; - } else - return null; -} - -export function get_fstab_config(block, also_child_config) { - return get_fstab_config_with_client(client, block, also_child_config); -} - -function find_blocks_for_mount_point(client, mount_point, self) { - const blocks = []; - - function is_self(b) { - return self && (b == self || client.blocks[b.CryptoBackingDevice] == self); - } - - for (const p in client.blocks) { - const b = client.blocks[p]; - const [, dir] = get_fstab_config(b); - if (dir == mount_point && !is_self(b)) - blocks.push(b); - } - - return blocks; -} - -function nice_block_name(block) { - return utils.block_name(client.blocks[block.CryptoBackingDevice] || block); -} - -export function is_valid_mount_point(client, block, val, format_only, for_fstab) { - if (val === "") { - if (!format_only || for_fstab) - return _("Mount point cannot be empty"); - return null; - } - - const other_blocks = find_blocks_for_mount_point(client, val, block); - if (other_blocks.length > 0) - return cockpit.format(_("Mount point is already used for $0"), - other_blocks.map(nice_block_name).join(", ")); - - if (!format_only) { - const children = utils.find_children_for_mount_point(client, val, block); - if (Object.keys(children).length > 0) - return <> - {_("Filesystems are already mounted below this mountpoint.")} - {Object.keys(children).map(m =>
{cockpit.format("• $0 on $1", nice_block_name(children[m]), m)}
)} - {_("Please unmount them first.")} - ; - } -} - -export function get_cryptobacking_noauto(client, block) { - const crypto_backing = block.IdUsage == "crypto" ? block : client.blocks[block.CryptoBackingDevice]; - if (!crypto_backing) - return false; - - const crypto_config = crypto_backing.Configuration.find(c => c[0] == "crypttab"); - if (!crypto_config) - return false; - - const crypto_options = utils.decode_filename(crypto_config[1].options.v).split(",") - .map(o => o.trim()); - return crypto_options.indexOf("noauto") >= 0; -} - -export function check_mismounted_fsys(client, path, enter_warning) { - const block = client.blocks[path]; - const block_fsys = client.blocks_fsys[path]; - const is_locked_crypto = block.IdUsage == "crypto" && !client.blocks_cleartext[path]; - const [, dir, opts] = get_fstab_config(block, is_locked_crypto); - - if (!block || !(block_fsys || dir)) - return; - - const mounted_at = block_fsys ? block_fsys.MountPoints.map(utils.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, 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) - enter_warning(path, { warning: "mismounted-fsys", type, other: other_mounts[0] }); -} - -export function mounting_dialog(client, block, mode, forced_options) { - const block_fsys = client.blocks_fsys[block.path]; - const [old_config, old_dir, old_opts, old_parents] = get_fstab_config(block, true); - const options = old_config ? old_opts : initial_tab_options(client, block, true); - - const split_options = parse_options(options); - extract_option(split_options, "noauto"); - const opt_never_auto = extract_option(split_options, "x-cockpit-never-auto"); - const opt_ro = extract_option(split_options, "ro"); - 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); - const extra_options = unparse_options(split_options); - - const is_filesystem_mounted = is_mounted(client, block); - - function maybe_update_config(new_dir, new_opts, passphrase, passphrase_type) { - let new_config = null; - 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; - - if (new_dir != "") { - if (new_dir[0] != "/") - new_dir = "/" + new_dir; - new_config = [ - "fstab", { - fsname: old_config ? old_config[1].fsname : undefined, - dir: { t: 'ay', v: utils.encode_filename(new_dir) }, - type: { t: 'ay', v: utils.encode_filename("auto") }, - opts: { t: 'ay', v: utils.encode_filename(all_new_opts || "defaults") }, - freq: { t: 'i', v: 0 }, - passno: { t: 'i', v: 0 }, - "track-parents": { t: 'b', v: !old_config } - }]; - } - - function undo() { - if (!old_config && new_config) - return block.RemoveConfigurationItem(new_config, {}); - else if (old_config && !new_config) - return block.AddConfigurationItem(old_config, {}); - else if (old_config && new_config && (new_dir != old_dir || new_opts != old_opts)) { - return block.UpdateConfigurationItem(new_config, old_config, {}); - } - } - - function get_block_fsys() { - if (block_fsys) - return Promise.resolve(block_fsys); - else - return client.wait_for(() => (client.blocks_cleartext[block.path] && - client.blocks_fsys[client.blocks_cleartext[block.path].path])); - } - - function maybe_mount() { - if (mode == "mount" || (mode == "update" && is_filesystem_mounted)) { - return (get_block_fsys() - .then(block_fsys => { - const block = client.blocks[block_fsys.path]; - return (client.mount_at(block, new_dir) - .catch(error => { - // systemd might have mounted the filesystem for us after - // unlocking, because fstab told it to. Ignore any error - // from mounting in that case. This only happens when this - // code runs to fix a inconsistent mount. - return (utils.is_mounted_synch(block) - .then(mounted_at => { - if (mounted_at == new_dir) - return; - return (undo() - .then(() => { - if (is_filesystem_mounted) - return client.mount_at(block, old_dir); - }) - .catch(ignored_error => { - console.warn("Error during undo:", ignored_error); - }) - .then(() => Promise.reject(error))); - })); - })); - })); - } else - return Promise.resolve(); - } - - function maybe_unlock() { - const crypto = client.blocks_crypto[block.path]; - if (mode == "mount" && crypto) { - return (unlock_with_type(client, block, passphrase, passphrase_type) - .catch(error => { - dlg.set_values({ needs_explicit_passphrase: true }); - return Promise.reject(error); - })); - } else - return Promise.resolve(); - } - - function maybe_lock() { - if (mode == "unmount") { - const crypto_backing = client.blocks[block.CryptoBackingDevice]; - const crypto_backing_crypto = crypto_backing && client.blocks_crypto[crypto_backing.path]; - if (crypto_backing_crypto) { - return crypto_backing_crypto.Lock({}); - } else - return Promise.resolve(); - } - } - - // We need to reload systemd twice: Once at the beginning so - // that it is up to date with whatever is currently in fstab, - // and once at the end to make it see our changes. Otherwise - // systemd might do some uexpected mounts/unmounts behind our - // backs. - - return (utils.reload_systemd() - .then(() => utils.teardown_active_usage(client, usage)) - .then(maybe_unlock) - .then(() => { - if (!old_config && new_config) - return (block.AddConfigurationItem(new_config, {}) - .then(maybe_mount)); - else if (old_config && !new_config) - return block.RemoveConfigurationItem(old_config, {}); - else if (old_config && new_config) - return (block.UpdateConfigurationItem(old_config, new_config, {}) - .then(maybe_mount)); - else if (new_config && !is_mounted(client, block)) - return maybe_mount(); - }) - .then(maybe_lock) - .then(utils.reload_systemd)); - } - - let at_boot; - if (opt_never_auto) - at_boot = "never"; - else if (opt_netdev) - at_boot = "netdev"; - else if (opt_nofail) - at_boot = "nofail"; - else - at_boot = "local"; - - let fields = null; - if (mode == "mount" || mode == "update") { - fields = [ - TextInput("mount_point", _("Mount point"), - { - value: old_dir, - validate: val => is_valid_mount_point(client, block, val, mode == "update" && !is_filesystem_mounted, true) - }), - CheckBoxes("mount_options", _("Mount options"), - { - value: { - ro: opt_ro, - extra: extra_options || false - }, - fields: [ - { title: _("Mount read only"), tag: "ro" }, - { title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" }, - ] - }), - SelectOne("at_boot", _("At boot"), - { - value: at_boot, - explanation: mount_explanation[at_boot], - 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"), - }, - ] - }), - ]; - - if (block.IdUsage == "crypto" && mode == "mount") - fields = fields.concat([ - PassInput("passphrase", _("Passphrase"), - { - visible: vals => vals.needs_explicit_passphrase, - validate: val => !val.length && _("Passphrase cannot be empty"), - }) - ]); - } - - const mode_title = { - mount: _("Mount filesystem"), - unmount: _("Unmount filesystem $0"), - update: _("Mount configuration") - }; - - const mode_action = { - mount: _("Mount"), - unmount: _("Unmount"), - update: _("Save") - }; - - function do_unmount() { - let opts = []; - opts.push("noauto"); - if (opt_ro) - opts.push("ro"); - if (opt_never_auto) - opts.push("x-cockpit-never-auto"); - if (opt_nofail) - opts.push("nofail"); - if (opt_netdev) - opts.push("_netdev"); - if (forced_options) - opts = opts.concat(forced_options); - if (extra_options) - opts = opts.concat(extra_options); - return (maybe_set_crypto_options(null, false, null, null) - .then(() => maybe_update_config(old_dir, unparse_options(opts)))); - } - - let passphrase_type; - - function maybe_set_crypto_options(readonly, auto, nofail, netdev) { - if (client.blocks_crypto[block.path]) { - return set_crypto_options(block, readonly, auto, nofail, netdev); - } else if (client.blocks_crypto[block.CryptoBackingDevice]) { - return set_crypto_options(client.blocks[block.CryptoBackingDevice], readonly, auto, nofail, netdev); - } else - return Promise.resolve(); - } - - const usage = utils.get_active_usage(client, block.path); - - const dlg = dialog_open({ - Title: cockpit.format(mode_title[mode], old_dir), - Fields: fields, - Teardown: TeardownMessage(usage, old_dir), - update: function (dlg, vals, trigger) { - if (trigger == "at_boot") - dlg.set_options("at_boot", { explanation: mount_explanation[vals.at_boot] }); - }, - Action: { - Title: mode_action[mode], - disable_on_error: usage.Teardown, - action: function (vals) { - if (mode == "unmount") { - return do_unmount(); - } else if (mode == "mount" || mode == "update") { - let opts = []; - if ((mode == "update" && !is_filesystem_mounted) || vals.at_boot == "never") - opts.push("noauto"); - if (vals.mount_options.ro) - opts.push("ro"); - if (vals.at_boot == "never") - opts.push("x-cockpit-never-auto"); - if (vals.at_boot == "nofail") - opts.push("nofail"); - if (vals.at_boot == "netdev") - opts.push("_netdev"); - if (forced_options) - opts = opts.concat(forced_options); - if (vals.mount_options.extra !== false) - opts = opts.concat(parse_options(vals.mount_options.extra)); - return (maybe_update_config(vals.mount_point, unparse_options(opts), - vals.passphrase, passphrase_type) - .then(() => maybe_set_crypto_options(vals.mount_options.ro, - opts.indexOf("noauto") == -1, - vals.at_boot == "nofail", - vals.at_boot == "netdev"))); - } - } - }, - Inits: [ - init_active_usage_processes(client, usage, old_dir), - (block.IdUsage == "crypto" && mode == "mount") - ? init_existing_passphrase(block, true, type => { passphrase_type = type }) - : null - ] - }); -} - -export class FilesystemTab extends React.Component { - constructor(props) { - super(props); - this.onSamplesChanged = this.onSamplesChanged.bind(this); - } - - onSamplesChanged() { - if (!this.props.client.busy) - this.setState({}); - } - - componentDidMount() { - this.props.client.fsys_sizes.addEventListener("changed", this.onSamplesChanged); - } - - componentWillUnmount() { - this.props.client.fsys_sizes.removeEventListener("changed", this.onSamplesChanged); - } - - render() { - const self = this; - const block = self.props.block; - const forced_options = self.props.forced_options; - const is_locked = block && block.IdUsage == 'crypto'; - const block_fsys = block && self.props.client.blocks_fsys[block.path]; - const stratis_fsys = block && self.props.client.blocks_stratis_fsys[block.path]; - - const mismounted_fsys_warning = self.props.warnings.find(w => w.warning == "mismounted-fsys"); - - function rename_dialog() { - dialog_open({ - Title: _("Filesystem name"), - Fields: [ - TextInput("name", _("Name"), - { - validate: name => utils.validate_fsys_label(name, block.IdType), - value: block.IdLabel - }) - ], - Action: { - Title: _("Save"), - action: function (vals) { - return block_fsys.SetLabel(vals.name, {}); - } - } - }); - } - - const is_filesystem_mounted = is_mounted(self.props.client, block); - const [old_config, old_dir, old_opts, old_parents] = get_fstab_config(block, true); - 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"); - const split_options_for_fix_config = split_options.slice(); - 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 (block.CryptoBackingDevice != "/") { - 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}; - - function fix_config() { - const { type, other } = mismounted_fsys_warning; - - 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: utils.encode_filename(new_dir) }, - type: { t: 'ay', v: utils.encode_filename("auto") }, - opts: { t: 'ay', v: utils.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() { - const crypto_backing = (block.IdUsage == "crypto") ? block : client.blocks[block.CryptoBackingDevice]; - if (!crypto_backing) - return; - if (type == "no-mount-on-boot") - return set_crypto_auto_option(crypto_backing, true); - if (type == "locked-on-boot-mount") - return set_crypto_auto_option(crypto_backing, false); - } - - function fixup_fsys() { - if (old_config) - return block.UpdateConfigurationItem(old_config, new_config, {}).then(utils.reload_systemd); - else - return block.AddConfigurationItem(new_config, {}).then(utils.reload_systemd); - } - - return fixup_fsys().then(fixup_crypto_backing); - } - - function fix_mount() { - const { type, other } = mismounted_fsys_warning; - const crypto_backing = (block.IdUsage == "crypto") ? block : client.blocks[block.CryptoBackingDevice]; - const crypto_backing_crypto = crypto_backing && client.blocks_crypto[crypto_backing.path]; - - function do_mount() { - if (crypto_backing == block) - mounting_dialog(client, block, "mount", forced_options); - else - return client.mount_at(block, old_dir); - } - - function do_unmount() { - return client.unmount_at(old_dir) - .then(() => { - if (crypto_backing) - return crypto_backing_crypto.Lock({}); - }); - } - - if (type == "change-mount-on-boot") - return client.unmount_at(other).then(() => client.mount_at(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 (crypto_backing) - return set_crypto_auto_option(crypto_backing, true); - } - } - - let mismounted_section = null; - if (mismounted_fsys_warning) { - const { type, other } = mismounted_fsys_warning; - 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"); - } - - mismounted_section = ( - <> -
- - {text} -
- {fix_config_text} - { fix_mount_text && {fix_mount_text} } -
-
- ); - } - - return ( -
- - { !stratis_fsys && - - {_("Name")} - - - {this.props.block.IdLabel || "-"} - - - {_("edit")} - - - - - } - - {_("Mount point")} - - { mount_point_text && - - { mount_point_text } - - mounting_dialog(self.props.client, block, "update", - forced_options)}> - {_("edit")} - - - - } - { extra_text } - - - - { mismounted_section } -
- ); - } -} diff --git a/pkg/storaged/icons/gnome-icons.jsx b/pkg/storaged/icons/gnome-icons.jsx new file mode 100644 index 000000000000..d51d3e1da06b --- /dev/null +++ b/pkg/storaged/icons/gnome-icons.jsx @@ -0,0 +1,48 @@ +/* Icons from the GNOME projetc, http://www.gnome.org + + This work is licenced under the terms of either the GNU LGPL v3 or + Creative Commons Attribution-Share Alike 3.0 United States License. + + To view a copy of the CC-BY-SA licence, visit + + http://creativecommons.org/licenses/by-sa/3.0/ or send a letter to Creative + + Commons, 171 Second Street, Suite 300, San Francisco, California 94105, USA. +*/ + +import React from "react"; + +export const NetworkIcon = () => { + // network-wired-symbolic.svg + return + + ; +}; + +export const VolumeIcon = () => { + // drive-multidisk-symbolic.svg + return + + ; +}; + +export const HDDIcon = () => { + // drive-harddisk-symbolic.svg + return + + ; +}; + +export const SSDIcon = () => { + // drive-harddisk-solidstate-symbolic.svg + return + + ; +}; + +export const MediaDriveIcon = () => { + // drive-removable-media-symbolic.svg + return + + ; +}; diff --git a/pkg/storaged/images/storage-array.png b/pkg/storaged/images/storage-array.png deleted file mode 100644 index 20dd775f387b..000000000000 Binary files a/pkg/storaged/images/storage-array.png and /dev/null differ diff --git a/pkg/storaged/images/storage-disk.png b/pkg/storaged/images/storage-disk.png deleted file mode 100644 index 08835e17a55b..000000000000 Binary files a/pkg/storaged/images/storage-disk.png and /dev/null differ diff --git a/pkg/storaged/iscsi-panel.jsx b/pkg/storaged/iscsi/create-dialog.jsx similarity index 61% rename from pkg/storaged/iscsi-panel.jsx rename to pkg/storaged/iscsi/create-dialog.jsx index 8a97649a20bb..519f6f87e51c 100644 --- a/pkg/storaged/iscsi-panel.jsx +++ b/pkg/storaged/iscsi/create-dialog.jsx @@ -1,7 +1,7 @@ /* * This file is part of Cockpit. * - * Copyright (C) 2017 Red Hat, Inc. + * 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 @@ -18,19 +18,13 @@ */ import cockpit from "cockpit"; -import React from "react"; +import client from "../client.js"; -import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; -import { CheckIcon, EditIcon, PlusIcon, TrashIcon } from "@patternfly/react-icons"; - -import { SidePanel } from "./side-panel.jsx"; -import { } from "./utils.js"; -import { StorageButton } from "./storage-controls.jsx"; -import { dialog_open, TextInput, PassInput, SelectRow } from "./dialog.jsx"; +import { dialog_open, TextInput, PassInput, SelectRow } from "../dialog.jsx"; const _ = cockpit.gettext; -function iscsi_discover(client) { +export function iscsi_discover() { dialog_open({ Title: _("Add iSCSI portal"), Fields: [ @@ -57,7 +51,7 @@ function iscsi_discover(client) { .then(function (results) { if (!cancelled) { resolve(); - iscsi_add(client, vals, results[0]); + iscsi_add(vals, results[0]); } }) .catch(function (error) { @@ -91,7 +85,7 @@ function iscsi_discover(client) { }); } -function iscsi_login(client, target, cred_vals) { +function iscsi_login(target, cred_vals) { const options = { 'node.startup': { t: 's', v: "automatic" } }; @@ -109,7 +103,7 @@ function iscsi_login(client, target, cred_vals) { ]); } -function iscsi_add(client, discover_vals, nodes) { +function iscsi_add(discover_vals, nodes) { dialog_open({ Title: cockpit.format(_("Available targets on $0"), discover_vals.address), @@ -125,10 +119,10 @@ function iscsi_add(client, discover_vals, nodes) { Action: { Title: _("Add"), action: function (vals) { - return iscsi_login(client, vals.target, discover_vals) + return iscsi_login(vals.target, discover_vals) .catch(err => { if (err.message.indexOf("authorization") != -1) - iscsi_add_with_creds(client, discover_vals, vals); + iscsi_add_with_creds(discover_vals, vals); else return Promise.reject(err); }); @@ -137,7 +131,7 @@ function iscsi_add(client, discover_vals, nodes) { }); } -function iscsi_add_with_creds(client, discover_vals, login_vals) { +function iscsi_add_with_creds(discover_vals, login_vals) { dialog_open({ Title: _("Authentication required"), Fields: [ @@ -149,7 +143,7 @@ function iscsi_add_with_creds(client, discover_vals, login_vals) { Action: { Title: _("Add"), action: function (vals) { - return iscsi_login(client, login_vals.target, vals) + return iscsi_login(login_vals.target, vals) .catch(err => { // HACK - https://github.com/storaged-project/udisks/issues/26 if (err.message.indexOf("authorization") != -1) @@ -164,7 +158,7 @@ function iscsi_add_with_creds(client, discover_vals, login_vals) { }); } -function iscsi_change_name(client) { +export function iscsi_change_name() { return client.manager_iscsi.call('GetInitiatorName') .then(function (results) { const name = results[0]; @@ -185,99 +179,3 @@ function iscsi_change_name(client) { }); }); } - -export function iscsi_rows(client, options) { - function cmp_session(path_a, path_b) { - const a = client.iscsi_sessions[path_a]; - const b = client.iscsi_sessions[path_b]; - const a_name = a.data.target_name || ""; - const b_name = b.data.target_name || ""; - - return a_name.localeCompare(b_name); - } - - function make_session(path) { - const session = client.iscsi_sessions[path]; - - function iscsi_remove() { - if (options.disarm) - options.disarm(); - return session.Logout({ 'node.startup': { t: 's', v: "manual" } }); - } - - let actions = null; - if (options.armed) - actions = ( - - - - ); - - return { - client, - actions, - kind: "array", - name: session.data.target_name || "", - key: path, - detail: session.data.persistent_address + ":" + session.data.persistent_port - }; - } - - return Object.keys(client.iscsi_sessions).sort(cmp_session) - .map(make_session); -} - -export class IscsiPanel extends React.Component { - constructor() { - super(); - this.state = { armed: false }; - } - - render() { - const client = this.props.client; - const sessions = iscsi_rows(client, - { armed: this.state.armed, disarm: () => this.setState({ armed: false }) }); - - const toggle_armed = (event) => { - if (!event || event.button !== 0) - return; - this.setState(prevState => ({ armed: !prevState.armed })); - }; - - const actions = ( - <> - { sessions.length > 0 - ? - : null - } - { "\n" } - iscsi_change_name(client)} id="edit-iscsi"> - - - { "\n" } - iscsi_discover(client)} - id="add-iscsi-portal"> - - - - ); - - const iscsi_feature = { - is_enabled: () => client.features.iscsi - }; - - return ( - - ); - } -} diff --git a/pkg/storaged/iscsi/session.jsx b/pkg/storaged/iscsi/session.jsx new file mode 100644 index 000000000000..eae276ec173f --- /dev/null +++ b/pkg/storaged/iscsi/session.jsx @@ -0,0 +1,94 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { NetworkIcon } from "../icons/gnome-icons.jsx"; +import { StorageDescription, new_page, new_card, ChildrenTable, StorageCard } from "../pages.jsx"; + +import { make_drive_page } from "../drive/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 session_card = new_card({ + title: _("iSCSI portal"), + next: null, + page_location: ["iscsi", session.data.target_name], + page_name: session.data.target_name, + page_icon: NetworkIcon, + component: ISCSISessionCard, + props: { session }, + actions: [ + { + title: _("Disconnect"), + action: () => disconnect(session, parent), + danger: true + }, + ] + }); + + const drives_card = new_card({ + title: _("iSCSI drives"), + next: session_card, + component: ISCSIDrivesCard, + props: { session }, + }); + + const p = new_page(parent, drives_card); + + if (client.iscsi_sessions_drives[session.path]) + client.iscsi_sessions_drives[session.path].forEach(d => make_drive_page(p, d)); +} + +const ISCSIDrivesCard = ({ card, session }) => { + return ( + + + + + + ); +}; + +const ISCSISessionCard = ({ card, session }) => { + return ( + + + + + + + + + ); +}; diff --git a/pkg/storaged/jobs-panel.jsx b/pkg/storaged/jobs-panel.jsx index dd544dd9c808..1be1e54cb837 100644 --- a/pkg/storaged/jobs-panel.jsx +++ b/pkg/storaged/jobs-panel.jsx @@ -20,8 +20,9 @@ import cockpit from "cockpit"; import React from "react"; -import { Card, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; import { DataList, DataListCell, DataListItem, DataListItemCells, DataListItemRow } from "@patternfly/react-core/dist/esm/components/DataList/index.js"; +import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js"; import { StorageButton } from "./storage-controls.jsx"; import { block_name, mdraid_name, lvol_name, format_delay } from "./utils.js"; @@ -59,15 +60,15 @@ const descriptions = { cleanup: _("Cleaning up for $target"), 'ata-secure-erase': _("Securely erasing $target"), 'ata-enhanced-secure-erase': _("Very securely erasing $target"), - 'md-raid-stop': _("Stopping RAID device $target"), - 'md-raid-start': _("Starting RAID device $target"), + 'md-raid-stop': _("Stopping MDRAID device $target"), + 'md-raid-start': _("Starting MDRAID device $target"), 'md-raid-fault-device': _("Marking $target as faulty"), - 'md-raid-remove-device': _("Removing $target from RAID device"), - 'md-raid-create': _("Creating RAID device $target"), - 'mdraid-check-job': _("Checking RAID device $target"), - 'mdraid-repair-job': _("Checking and repairing RAID device $target"), - 'mdraid-recover-job': _("Recovering RAID device $target"), - 'mdraid-sync-job': _("Synchronizing RAID device $target"), + 'md-raid-remove-device': _("Removing $target from MDRAID device"), + 'md-raid-create': _("Creating MDRAID device $target"), + 'mdraid-check-job': _("Checking MDRAID device $target"), + 'mdraid-repair-job': _("Checking and repairing MDRAID device $target"), + 'mdraid-recover-job': _("Recovering MDRAID device $target"), + 'mdraid-sync-job': _("Synchronizing MDRAID device $target"), 'lvm-lvol-delete': _("Deleting $target"), 'lvm-lvol-activate': _("Activating $target"), 'lvm-lvol-deactivate': _("Deactivating $target"), @@ -123,13 +124,16 @@ class JobRow extends React.Component { + + , {make_description(this.props.client, job)} , {job.ProgressValid && (job.Progress * 100).toFixed() + "%"} , - + {remaining} , @@ -158,20 +162,19 @@ export class JobsPanel extends React.Component { render() { const client = this.props.client; + const path_jobs = client.path_jobs[this.props.path] || []; const server_now = new Date().getTime() + client.time_offset; - function cmp_job(path_a, path_b) { - return client.jobs[path_a].StartTime - client.jobs[path_b].StartTime; + function cmp_job(job_a, job_b) { + return job_a.StartTime - job_b.StartTime; } - function job_is_stable(path) { - const j = client.jobs[path]; - - const age_ms = server_now - j.StartTime / 1000; + function job_is_stable(job) { + const age_ms = server_now - job.StartTime / 1000; if (age_ms >= 2000) return true; - if (j.ExpectedEndTime > 0 && (j.ExpectedEndTime / 1000 - server_now) >= 2000) + if (job.ExpectedEndTime > 0 && (job.ExpectedEndTime / 1000 - server_now) >= 2000) return true; return false; @@ -179,9 +182,9 @@ export class JobsPanel extends React.Component { let jobs = []; let have_reminder = false; - for (const p in client.jobs) { - if (job_is_stable(p)) { - jobs.push(p); + for (const j of path_jobs) { + if (job_is_stable(j)) { + jobs.push(j); } else if (!have_reminder) { // If there is a unstable job, we have to check again in a bit since being // stable or not depends on the current time. @@ -198,14 +201,11 @@ export class JobsPanel extends React.Component { jobs = jobs.sort(cmp_job); return ( - - {_("Jobs")} - - - { jobs.map((p) => ) } - - - + + + { jobs.map(j => ) } + + ); } } @@ -213,8 +213,9 @@ export class JobsPanel extends React.Component { export function job_progress_wrapper(client, path1, path2) { return function (vals, progress_callback, action_function) { function client_changed() { - const job = client.path_jobs[path1] || client.path_jobs[path2]; - if (job) { + const jobs = client.path_jobs[path1] || client.path_jobs[path2]; + if (jobs && jobs.length > 0) { + const job = jobs[0]; let desc = make_description(client, job); if (job.ProgressValid) desc += cockpit.format(" ($0%)", (job.Progress * 100).toFixed()); diff --git a/pkg/storaged/vdo-details.jsx b/pkg/storaged/legacy-vdo/legacy-vdo.jsx similarity index 57% rename from pkg/storaged/vdo-details.jsx rename to pkg/storaged/legacy-vdo/legacy-vdo.jsx index 0709d1bcb6cd..198c3904295f 100644 --- a/pkg/storaged/vdo-details.jsx +++ b/pkg/storaged/legacy-vdo/legacy-vdo.jsx @@ -1,7 +1,7 @@ /* * This file is part of Cockpit. * - * Copyright (C) 2017 Red Hat, Inc. + * 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 @@ -19,23 +19,148 @@ import cockpit from "cockpit"; import React from "react"; +import client from "../client"; + import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; -import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js'; +import { Card, 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 { get_active_usage, teardown_active_usage, fmt_size, decode_filename, reload_systemd } from "./utils.js"; + +import { block_short_name, get_active_usage, teardown_active_usage, fmt_size, decode_filename, reload_systemd } from "../utils.js"; import { dialog_open, SizeSlider, BlockingMessage, TeardownMessage, init_active_usage_processes -} from "./dialog.jsx"; -import { StdDetailsLayout } from "./details.jsx"; -import { Block } from "./content-views.jsx"; -import { StorageButton, StorageOnOff, StorageBlockNavLink } from "./storage-controls.jsx"; +} from "../dialog.jsx"; +import { StorageButton, StorageOnOff } from "../storage-controls.jsx"; + +import { StorageCard, new_page, new_card } from "../pages.jsx"; +import { make_block_page } from "../block/create-pages.jsx"; import inotify_py from "inotify.py"; import vdo_monitor_py from "./vdo-monitor.py"; const _ = cockpit.gettext; -export class VDODetails extends React.Component { +export function make_legacy_vdo_page(parent, vdo, backing_block, next_card) { + const block = client.slashdevs_block[vdo.dev]; + + function stop() { + const usage = get_active_usage(client, block ? block.path : "/", _("stop")); + + if (usage.Blocking) { + dialog_open({ + Title: cockpit.format(_("$0 is in use"), vdo.name), + Body: BlockingMessage(usage), + }); + return; + } + + if (usage.Teardown) { + dialog_open({ + Title: cockpit.format(_("Confirm stopping of $0"), + vdo.name), + Teardown: TeardownMessage(usage), + Action: { + Title: _("Stop"), + action: function () { + return teardown_active_usage(client, usage) + .then(function () { + return vdo.stop(); + }); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); + } else { + return vdo.stop(); + } + } + + function delete_() { + const usage = get_active_usage(client, block ? block.path : "/", _("delete")); + + if (usage.Blocking) { + dialog_open({ + Title: cockpit.format(_("$0 is in use"), vdo.name), + Body: BlockingMessage(usage), + }); + return; + } + + function wipe_with_teardown(block) { + return block.Format("empty", { 'tear-down': { t: 'b', v: true } }).then(reload_systemd); + } + + function teardown_configs() { + if (block) { + return wipe_with_teardown(block); + } else { + return vdo.start() + .then(function () { + return client.wait_for(() => client.slashdevs_block[vdo.dev]) + .then(function (block) { + return wipe_with_teardown(block) + .catch(error => { + // systemd might have mounted it, let's try unmounting + const block_fsys = client.blocks_fsys[block.path]; + if (block_fsys) { + return block_fsys.Unmount({}) + .then(() => wipe_with_teardown(block)); + } else { + return Promise.reject(error); + } + }); + }); + }); + } + } + + dialog_open({ + Title: cockpit.format(_("Permanently delete $0?"), vdo.name), + Body: TeardownMessage(usage), + Action: { + Title: _("Delete"), + Danger: _("Deleting erases all data on a VDO device."), + action: function () { + return (teardown_active_usage(client, usage) + .then(teardown_configs) + .then(function () { + return vdo.remove(); + })); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); + } + + const vdo_card = new_card({ + title: cockpit.format(_("VDO device $0"), vdo.name), + next: next_card, + page_location: ["vdo", vdo.name], + page_name: block_short_name(backing_block), + page_size: vdo.logical_size, + job_path: backing_block.path, + component: VDODetails, + props: { client, vdo }, + actions: [ + (block + ? { title: _("Stop"), action: stop } + : { title: _("Start"), action: () => vdo.start() } + ), + { title: _("Delete"), action: delete_, danger: true } + ], + }); + + if (block) { + make_block_page(parent, block, vdo_card); + } else { + new_page(parent, vdo_card); + } +} + +class VDODetails extends React.Component { constructor() { super(); this.poll_path = null; @@ -91,132 +216,31 @@ export class VDODetails extends React.Component { const backing_block = client.slashdevs_block[vdo.backing_dev]; function force_delete() { - const location = cockpit.location; - return vdo.force_remove().then(function () { - location.go("/"); - }); + return vdo.force_remove(); } if (vdo.broken) { - const broken = ( - - {_("Remove device")} - _("The creation of this VDO device did not finish and the device can't be used.") - } /> + return ( + + + {_("Remove device")} + } /> + ); - return ; } - let alert = null; + const alerts = []; if (backing_block && backing_block.Size > vdo.physical_size) - alert = ( - {_("Grow to take all space")}} - title={_("This VDO device does not use all of its backing device.")}> + alerts.push( + {_("Grow to take all space")}} + title={_("This VDO device does not use all of its backing device.")}> { cockpit.format(_("Only $0 of $1 are used."), fmt_size(vdo.physical_size), fmt_size(backing_block.Size))} - - ); - - function stop() { - const usage = get_active_usage(client, block ? block.path : "/", _("stop")); - - if (usage.Blocking) { - dialog_open({ - Title: cockpit.format(_("$0 is in use"), vdo.name), - Body: BlockingMessage(usage), - }); - return; - } - - if (usage.Teardown) { - dialog_open({ - Title: cockpit.format(_("Confirm stopping of $0"), - vdo.name), - Teardown: TeardownMessage(usage), - Action: { - Title: _("Stop"), - disable_on_error: usage.Teardown, - action: function () { - return teardown_active_usage(client, usage) - .then(function () { - return vdo.stop(); - }); - } - }, - Inits: [ - init_active_usage_processes(client, usage) - ] - }); - } else { - return vdo.stop(); - } - } - - function delete_() { - const usage = get_active_usage(client, block ? block.path : "/", _("delete")); - - if (usage.Blocking) { - dialog_open({ - Title: cockpit.format(_("$0 is in use"), vdo.name), - Body: BlockingMessage(usage), - }); - return; - } - - function wipe_with_teardown(block) { - return block.Format("empty", { 'tear-down': { t: 'b', v: true } }).then(reload_systemd); - } - - function teardown_configs() { - if (block) { - return wipe_with_teardown(block); - } else { - return vdo.start() - .then(function () { - return client.wait_for(() => client.slashdevs_block[vdo.dev]) - .then(function (block) { - return wipe_with_teardown(block) - .catch(error => { - // systemd might have mounted it, let's try unmounting - const block_fsys = client.blocks_fsys[block.path]; - if (block_fsys) { - return block_fsys.Unmount({}) - .then(() => wipe_with_teardown(block)); - } else { - return Promise.reject(error); - } - }); - }); - }); - } - } - - dialog_open({ - Title: cockpit.format(_("Permanently delete $0?"), vdo.name), - Body: TeardownMessage(usage), - Action: { - Title: _("Delete"), - Danger: _("Deleting erases all data on a VDO device."), - disable_on_error: usage.Teardown, - action: function () { - return (teardown_active_usage(client, usage) - .then(teardown_configs) - .then(function () { - const location = cockpit.location; - return vdo.remove().then(function () { - location.go("/"); - }); - })); - } - }, - Inits: [ - init_active_usage_processes(client, usage) - ] - }); - } + ); function grow_logical() { dialog_open({ @@ -256,19 +280,7 @@ export class VDODetails extends React.Component { const stats = this.state.stats; const header = ( - - - { block - ? {_("Stop")} - : {_("Start")} - } - { "\n" } - {_("Delete")} - , - }}> - {cockpit.format(_("VDO device $0"), vdo.name)} - + @@ -276,16 +288,6 @@ export class VDODetails extends React.Component { {vdo.dev} - - {_("Backing device")} - - { backing_block - ? - : vdo.backing_dev - } - - - {_("Physical")} @@ -338,11 +340,9 @@ export class VDODetails extends React.Component { - + ); - const content = ; - - return ; + return header; } } diff --git a/pkg/storaged/vdo-monitor.py b/pkg/storaged/legacy-vdo/vdo-monitor.py similarity index 100% rename from pkg/storaged/vdo-monitor.py rename to pkg/storaged/legacy-vdo/vdo-monitor.py diff --git a/pkg/storaged/lvm2/block-logical-volume.jsx b/pkg/storaged/lvm2/block-logical-volume.jsx new file mode 100644 index 000000000000..7331d860afb0 --- /dev/null +++ b/pkg/storaged/lvm2/block-logical-volume.jsx @@ -0,0 +1,410 @@ +/* + * 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 hopeg 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 { ExclamationTriangleIcon, ExclamationCircleIcon } from "@patternfly/react-icons"; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; + +import { StorageButton, StorageLink } from "../storage-controls.jsx"; + +import { check_unused_space, get_resize_info, grow_dialog, shrink_dialog } from "../block/resize.jsx"; +import { StorageCard, StorageDescription, new_card, navigate_to_new_card_location, navigate_away_from_card } from "../pages.jsx"; +import { block_name, fmt_size, get_active_usage, teardown_active_usage, reload_systemd } from "../utils.js"; +import { + dialog_open, TextInput, SelectSpaces, BlockingMessage, TeardownMessage, + init_active_usage_processes +} from "../dialog.jsx"; + +import { lvm2_create_snapshot_action } from "./volume-group.jsx"; +import { pvs_to_spaces } from "./utils.jsx"; + +const _ = cockpit.gettext; + +export function lvol_rename(lvol) { + dialog_open({ + Title: _("Rename logical volume"), + Fields: [ + TextInput("name", _("Name"), + { value: lvol.Name }) + ], + Action: { + Title: _("Rename"), + action: function (vals) { + return lvol.Rename(vals.name, { }); + } + } + }); +} + +export function lvol_delete(lvol, card) { + const vgroup = client.vgroups[lvol.VolumeGroup]; + const block = client.lvols_block[lvol.path]; + const usage = get_active_usage(client, block ? block.path : lvol.path, _("delete")); + + if (usage.Blocking) { + dialog_open({ + Title: cockpit.format(_("$0 is in use"), lvol.Name), + Body: BlockingMessage(usage) + }); + return; + } + + dialog_open({ + Title: cockpit.format(_("Permanently delete logical volume $0/$1?"), vgroup.Name, lvol.Name), + Teardown: TeardownMessage(usage), + Action: { + Danger: _("Deleting a logical volume will delete all data in it."), + Title: _("Delete"), + action: async function () { + await teardown_active_usage(client, usage); + await lvol.Delete({ 'tear-down': { t: 'b', v: true } }); + await reload_systemd(); + navigate_away_from_card(card); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); +} + +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 deactivate(lvol, block) { + const vgroup = client.vgroups[lvol.VolumeGroup]; + const usage = get_active_usage(client, block.path, _("deactivate")); + + if (usage.Blocking) { + dialog_open({ + Title: cockpit.format(_("$0 is in use"), lvol.Name), + Body: BlockingMessage(usage) + }); + return; + } + + dialog_open({ + Title: cockpit.format(_("Deactivate logical volume $0/$1?"), vgroup.Name, lvol.Name), + Teardown: TeardownMessage(usage), + Action: { + Title: _("Deactivate"), + action: async function () { + await teardown_active_usage(client, usage); + await lvol.Deactivate({ }); + await reload_systemd(); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); +} + +export function make_block_logical_volume_card(next, vgroup, lvol, block) { + const unused_space_warning = check_unused_space(block.path); + const unused_space = !!unused_space_warning; + const status_code = client.lvols_status[lvol.path]; + const pool = client.lvols[lvol.ThinPool]; + + 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"); + } + + let repair_action = null; + if (status_code == "degraded" || status_code == "degraded-maybe-partial") + repair_action = { title: _("Repair"), action: () => repair(lvol) }; + + const card = new_card({ + title: _("LVM2 logical volume"), + next, + page_location: ["vg", vgroup.Name, lvol.Name], + page_name: lvol.Name, + page_size: block.Size, + for_summary: true, + has_warning: !!unused_space_warning || !!repair_action, + has_danger: status_code == "partial", + job_path: lvol.path, + component: LVM2LogicalVolumeCard, + props: { vgroup, lvol, block, unused_space_warning, resize_info: info }, + actions: [ + (!unused_space && + { + title: _("Shrink"), + action: () => shrink_dialog(client, lvol, info), + excuse: shrink_excuse, + }), + (!unused_space && + { + title: _("Grow"), + action: () => grow_dialog(client, lvol, info), + excuse: grow_excuse, + }), + { + title: _("Deactivate"), + action: () => deactivate(lvol, block), + }, + lvm2_create_snapshot_action(lvol), + repair_action, + { + title: _("Delete"), + action: () => lvol_delete(lvol, card), + danger: true, + }, + ], + }); + return card; +} + +const LVM2LogicalVolumeCard = ({ card, vgroup, lvol, block, unused_space_warning, resize_info }) => { + const unused_space = !!unused_space_warning; + + function rename() { + dialog_open({ + Title: _("Rename logical volume"), + Fields: [ + TextInput("name", _("Name"), + { value: lvol.Name }) + ], + Action: { + Title: _("Rename"), + action: async function (vals) { + await lvol.Rename(vals.name, { }); + navigate_to_new_card_location(card, ["vg", vgroup.Name, vals.name]); + } + } + }); + } + + function shrink_to_fit() { + return shrink_dialog(client, lvol, resize_info, true); + } + + function grow_to_fit() { + return grow_dialog(client, lvol, resize_info, true); + } + + 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 ( + + {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")} +
+
}> + + + + {_("edit")} + } /> + { !unused_space && + + } + { (layout && layout != "linear") && + + } + + + + ); +}; + +export const StructureDescription = ({ client, lvol }) => { + const vgroup = client.vgroups[lvol.VolumeGroup]; + const pvs = (vgroup && client.vgroups_pvols[vgroup.path]) || []; + + if (!lvol.Structure || pvs.length <= 1) + return null; + + let status = null; + const status_code = client.lvols_status[lvol.path]; + if (status_code == "partial") { + status = _("This logical volume has lost some of its physical volumes and can no longer be used. You need to delete it and create a new one to take its place."); + } else if (status_code == "degraded") { + status = _("This logical volume has lost some of its physical volumes but has not lost any data yet. You should repair it to restore its original redundancy."); + } else if (status_code == "degraded-maybe-partial") { + status = _("This logical volume has lost some of its physical volumes but might not have lost any data yet. You might be able to repair it."); + } + + function nice_block_name(block) { + return block_name(client.blocks[block.CryptoBackingDevice] || block); + } + + function pvs_box(used, block_path) { + if (block_path != "/") { + const block = client.blocks[block_path]; + return
+
+ {block ? nice_block_name(block).replace("/dev/", "") : "???"} +
+
{fmt_size(used)}
+
; + } else { + return
+
+ { status_code == "degraded" + ? + : + } +
+
{fmt_size(used)}
+
; + } + } + + if (lvol.Layout == "linear") { + const pvs = client.lvols_stripe_summary[lvol.path]; + if (!pvs) + return null; + + const stripe = Object.keys(pvs).map((path, i) => + + {pvs_box(pvs[path], path)} + ); + + return ( + + + {stripe} + + {status} + ); + } + + function stripe_box(used, block_path) { + if (block_path != "/") { + const block = client.blocks[block_path]; + return
+
+ {block ? nice_block_name(block).replace("/dev/", "") : "???"} +
+
{fmt_size(used)}
+
; + } else { + return
+
+ { status_code == "degraded" + ? + : + } +
+
{fmt_size(used)}
+
; + } + } + + if (lvol.Layout == "mirror" || lvol.Layout.indexOf("raid") == 0) { + const summary = client.lvols_stripe_summary[lvol.path]; + if (!summary) + return null; + + const stripes = summary.map((pvs, i) => + + {Object.keys(pvs).map(path => stripe_box(pvs[path], path))} + ); + + return ( + <> + + {stripes} + {status} + {lvol.SyncRatio != 1.0 + ?
{cockpit.format(_("$0 synchronized"), lvol.SyncRatio * 100 + "%")}
+ : null} +
+ ); + } + + return null; +}; diff --git a/pkg/storaged/vgroups-panel.jsx b/pkg/storaged/lvm2/create-dialog.jsx similarity index 69% rename from pkg/storaged/vgroups-panel.jsx rename to pkg/storaged/lvm2/create-dialog.jsx index 4392baee92a8..cd19a36e6b1c 100644 --- a/pkg/storaged/vgroups-panel.jsx +++ b/pkg/storaged/lvm2/create-dialog.jsx @@ -1,7 +1,7 @@ /* * This file is part of Cockpit. * - * Copyright (C) 2017 Red Hat, Inc. + * 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 @@ -18,40 +18,14 @@ */ import cockpit from "cockpit"; +import client from "../client.js"; -import { - fmt_size, validate_lvm2_name, - get_available_spaces, prepare_available_spaces -} from "./utils.js"; -import { dialog_open, TextInput, SelectSpaces } from "./dialog.jsx"; +import { dialog_open, TextInput, SelectSpaces } from "../dialog.jsx"; +import { validate_lvm2_name, get_available_spaces, prepare_available_spaces } from "../utils.js"; const _ = cockpit.gettext; -function vgroup_row(client, path) { - const vgroup = client.vgroups[path]; - - return { - client, - kind: "array", - key: path, - name: vgroup.Name, - job_path: path, - devname: "/dev/" + vgroup.Name + "/", - detail: fmt_size(vgroup.Size) + " " + _("LVM2 volume group"), - go: () => cockpit.location.go(["vg", vgroup.Name]) - }; -} - -export function vgroup_rows(client, options) { - function cmp_vgroup(path_a, path_b) { - return client.vgroups[path_a].Name.localeCompare(client.vgroups[path_b].Name); - } - - return Object.keys(client.vgroups).sort(cmp_vgroup) - .map(p => vgroup_row(client, p)); -} - -export function create_vgroup(client) { +export function create_vgroup() { function find_vgroup(name) { for (const p in client.vgroups) { if (client.vgroups[p].Name == name) diff --git a/pkg/storaged/lvm2/create-logical-volume-dialog.jsx b/pkg/storaged/lvm2/create-logical-volume-dialog.jsx new file mode 100644 index 000000000000..b0496b3b790c --- /dev/null +++ b/pkg/storaged/lvm2/create-logical-volume-dialog.jsx @@ -0,0 +1,298 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2016 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 * as PK from "packagekit.js"; + +import { dialog_open, TextInput, SelectOne, Message, SelectSpaces, SelectOneRadioVertical, SizeSlider, CheckBoxes } from "../dialog.jsx"; +import { validate_lvm2_name } from "../utils.js"; + +import { pvs_to_spaces, next_default_logical_volume_name } from "./utils.jsx"; + +const _ = cockpit.gettext; + +function install_package(name, progress) { + return PK.check_missing_packages([name], p => progress(_("Checking installed software"), p.cancel)) + .then(data => { + if (data.unavailable_names.length > 0) + return Promise.reject(new Error( + cockpit.format(_("$0 is not available from any repository."), data.unavailable_names[0]))); + // let's be cautious here, we really don't expect removals + if (data.remove_names.length > 0) + return Promise.reject(new Error( + cockpit.format(_("Installing $0 would remove $1."), name, data.remove_names[0]))); + + return PK.install_missing_packages(data, p => progress(_("Installing packages"), p.cancel)); + }); +} + +export function create_logical_volume(client, vgroup) { + if (vgroup.FreeSize == 0) + return; + + const pvs_as_spaces = pvs_to_spaces(client, client.vgroups_pvols[vgroup.path].filter(pvol => pvol.FreeSize > 0)); + + const can_do_layouts = !!vgroup.CreatePlainVolumeWithLayout && pvs_as_spaces.length > 1; + + const purposes = [ + { + value: "block", + title: _("Block device for filesystems"), + }, + { value: "pool", title: _("Pool for thinly provisioned volumes") } + /* Not implemented + { value: "cache", Title: _("Cache") } + */ + ]; + + const layouts = [ + { + value: "linear", + title: _("Linear"), + min_pvs: 1, + }, + { + value: "raid0", + title: _("Striped (RAID 0)"), + min_pvs: 2, + }, + { + value: "raid1", + title: _("Mirrored (RAID 1)"), + min_pvs: 2, + }, + { + value: "raid10", + title: _("Striped and mirrored (RAID 10)"), + min_pvs: 4, + }, + { + value: "raid5", + title: _("Distributed parity (RAID 5)"), + min_pvs: 3, + }, + { + value: "raid6", + title: _("Double distributed parity (RAID 6)"), + min_pvs: 5, + } + ]; + + const vdo_package = client.get_config("vdo_package", null); + const need_vdo_install = vdo_package && !(client.features.lvm_create_vdo || client.features.legacy_vdo); + + if (client.features.lvm_create_vdo || client.features.legacy_vdo || vdo_package) + purposes.push({ value: "vdo", title: _("VDO filesystem volume (compression/deduplication)") }); + + /* For layouts with redundancy, CreatePlainVolumeWithLayout will + * create as many subvolumes as there are selected PVs. This has + * the nice effect of making the calculation of the maximum size of + * such a volume trivial. + */ + + function max_size(vals) { + const layout = vals.layout; + const pvs = vals.pvs.map(s => s.pvol); + const n_pvs = pvs.length; + const sum = pvs.reduce((sum, pv) => sum + pv.FreeSize, 0); + const min = Math.min.apply(null, pvs.map(pv => pv.FreeSize)); + + function metasize(datasize) { + const default_regionsize = 2 * 1024 * 1024; + const regions = Math.ceil(datasize / default_regionsize); + const bytes = 2 * 4096 + Math.ceil(regions / 8); + return vgroup.ExtentSize * Math.ceil(bytes / vgroup.ExtentSize); + } + + if (layout == "linear") { + return sum; + } else if (layout == "raid0" && n_pvs >= 2) { + return n_pvs * min; + } else if (layout == "raid1" && n_pvs >= 2) { + return min - metasize(min); + } else if (layout == "raid10" && n_pvs >= 4) { + return Math.floor(n_pvs / 2) * (min - metasize(min)); + } else if ((layout == "raid4" || layout == "raid5") && n_pvs >= 3) { + return (n_pvs - 1) * (min - metasize(min)); + } else if (layout == "raid6" && n_pvs >= 5) { + return (n_pvs - 2) * (min - metasize(min)); + } else + return 0; // not-covered: internal error + } + + const layout_descriptions = { + linear: _("Data will be stored on the selected physical volumes without any additional redundancy or performance improvements."), + raid0: _("Data will be stored on the selected physical volumes in an alternating fashion to improve performance. At least two volumes need to be selected."), + raid1: _("Data will be stored as two or more copies on the selected physical volumes, to improve reliability. At least two volumes need to be selected."), + raid10: _("Data will be stored as two copies and also in an alternating fashion on the selected physical volumes, to improve both reliability and performance. At least four volumes need to be selected."), + raid4: _("Data will be stored on the selected physical volumes so that one of them can be lost without affecting the data. At least three volumes need to be selected."), + raid5: _("Data will be stored on the selected physical volumes so that one of them can be lost without affecting the data. Data is also stored in an alternating fashion to improve performance. At least three volumes need to be selected."), + raid6: _("Data will be stored on the selected physical volumes so that up to two of them can be lost at the same time without affecting the data. Data is also stored in an alternating fashion to improve performance. At least five volumes need to be selected."), + }; + + function compute_layout_choices(pvs) { + return layouts.filter(l => l.min_pvs <= pvs.length); + } + + for (const lay of layouts) + lay.disabled = pvs_as_spaces.length < lay.min_pvs; + + function min_pvs_explanation(pvs, min) { + if (pvs.length <= min) + return cockpit.format(_("All $0 selected physical volumes are needed for the choosen layout."), + pvs.length); + return null; + } + + dialog_open({ + Title: _("Create logical volume"), + Fields: [ + TextInput("name", _("Name"), + { + value: next_default_logical_volume_name(client, vgroup, "lvol"), + validate: validate_lvm2_name + }), + SelectOne("purpose", _("Purpose"), + { + value: "block", + choices: purposes + }), + Message(cockpit.format(_("The $0 package will be installed to create VDO devices."), vdo_package), + { + visible: vals => vals.purpose === 'vdo' && need_vdo_install, + }), + SelectSpaces("pvs", _("Physical Volumes"), + { + spaces: pvs_as_spaces, + value: pvs_as_spaces, + visible: vals => can_do_layouts && vals.purpose === 'block', + min_selected: 1, + validate: (val, vals) => { + if (vals.layout == "raid10" && (vals.pvs.length % 2) !== 0) + return _("RAID10 needs an even number of physical volumes"); + }, + explanation: min_pvs_explanation(pvs_as_spaces, 1) + }), + SelectOneRadioVertical("layout", _("Layout"), + { + value: "linear", + choices: compute_layout_choices(pvs_as_spaces), + visible: vals => can_do_layouts && vals.purpose === 'block', + explanation: layout_descriptions.linear + }), + SizeSlider("size", _("Size"), + { + visible: vals => vals.purpose !== 'vdo', + max: vgroup.FreeSize, + round: vgroup.ExtentSize + }), + /* VDO parameters */ + SizeSlider("vdo_psize", _("Size"), + { + visible: vals => vals.purpose === 'vdo', + min: 5 * 1024 * 1024 * 1024, + max: vgroup.FreeSize, + round: vgroup.ExtentSize + }), + SizeSlider("vdo_lsize", _("Logical size"), + { + visible: vals => vals.purpose === 'vdo', + value: vgroup.FreeSize, + // visually point out that this can be over-provisioned + max: vgroup.FreeSize * 3, + allow_infinite: true, + round: vgroup.ExtentSize + }), + + CheckBoxes("vdo_options", _("Options"), + { + visible: vals => vals.purpose === 'vdo', + fields: [ + { + tag: "compression", + title: _("Compression"), + tooltip: _("Save space by compressing individual blocks with LZ4") + }, + { + tag: "deduplication", + title: _("Deduplication"), + tooltip: _("Save space by storing identical data blocks just once") + }, + ], + value: { + compression: true, + deduplication: true, + } + }), + ], + update: (dlg, vals, trigger) => { + if (vals.purpose == 'block' && (trigger == "layout" || trigger == "pvs" || trigger == "purpose")) { + for (const lay of layouts) { + if (lay.value == vals.layout) { + dlg.set_options("pvs", { + min_selected: lay.min_pvs, + explanation: min_pvs_explanation(vals.pvs, lay.min_pvs) + }); + } + } + dlg.set_options("layout", + { + choices: compute_layout_choices(vals.pvs), + explanation: layout_descriptions[vals.layout] + }); + const max = max_size(vals); + const old_max = dlg.get_options("size").max; + if (vals.size > max || vals.size == old_max) + dlg.set_values({ size: max }); + dlg.set_options("size", { max }); + } else if (trigger == "purpose") { + dlg.set_options("size", { max: vgroup.FreeSize }); + } + }, + Action: { + Title: _("Create"), + action: (vals, progress) => { + if (vals.purpose == "block") { + if (!can_do_layouts) + return vgroup.CreatePlainVolume(vals.name, vals.size, { }); + else { + return vgroup.CreatePlainVolumeWithLayout(vals.name, vals.size, vals.layout, + vals.pvs.map(spc => spc.block.path), + { }); + } + } else if (vals.purpose == "pool") + return vgroup.CreateThinPoolVolume(vals.name, vals.size, { }); + else if (vals.purpose == "vdo") { + return (need_vdo_install ? install_package(vdo_package, progress) : Promise.resolve()) + .then(() => { + progress(_("Creating VDO device")); // not cancellable any more + return vgroup.CreateVDOVolume( + // HACK: emulate lvcreate's automatic pool name creation until + // https://github.com/storaged-project/udisks/issues/939 + vals.name, next_default_logical_volume_name(client, vgroup, "vpool"), + vals.vdo_psize, vals.vdo_lsize, + 0, // default index memory + vals.vdo_options.compression, vals.vdo_options.deduplication, + "auto", { }); + }); + } + } + } + }); +} diff --git a/pkg/storaged/lvm2/inactive-logical-volume.jsx b/pkg/storaged/lvm2/inactive-logical-volume.jsx new file mode 100644 index 000000000000..3cc5654d1086 --- /dev/null +++ b/pkg/storaged/lvm2/inactive-logical-volume.jsx @@ -0,0 +1,47 @@ +/* + * 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 { + StorageCard, new_page, new_card +} from "../pages.jsx"; + +import { lvol_delete } from "./block-logical-volume.jsx"; +import { lvm2_create_snapshot_action } from "./volume-group.jsx"; + +const _ = cockpit.gettext; + +export function make_inactive_logical_volume_page(parent, vgroup, lvol, next_card) { + const inactive_card = new_card({ + title: _("Inactive logical volume"), + next: next_card, + page_location: ["vg", vgroup.Name, lvol.Name], + page_name: lvol.Name, + page_size: lvol.Size, + component: StorageCard, + actions: [ + { title: _("Activate"), action: () => lvol.Activate({}) }, + lvm2_create_snapshot_action(lvol), + { title: _("Delete"), action: () => lvol_delete(lvol, inactive_card), danger: true }, + ] + }); + + new_page(parent, inactive_card); +} diff --git a/pkg/storaged/lvm2/physical-volume.jsx b/pkg/storaged/lvm2/physical-volume.jsx new file mode 100644 index 000000000000..d3f2c1482779 --- /dev/null +++ b/pkg/storaged/lvm2/physical-volume.jsx @@ -0,0 +1,128 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { StorageCard, StorageDescription, new_card, register_crossref } from "../pages.jsx"; +import { format_dialog } from "../block/format-dialog.jsx"; +import { std_lock_action } from "../crypto/actions.jsx"; +import { StorageUsageBar } from "../storage-controls.jsx"; + +const _ = cockpit.gettext; + +export function make_lvm2_physical_volume_card(next, backing_block, content_block) { + const block_pvol = client.blocks_pvol[content_block.path]; + const vgroup = block_pvol && client.vgroups[block_pvol.VolumeGroup]; + + const pv_card = new_card({ + title: _("LVM2 physical volume"), + location: vgroup ? vgroup.Name : null, + next, + page_size: (block_pvol + ? + : backing_block.Size), + component: LVM2PhysicalVolumeCard, + 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 = _("Volume group is missing physical volumes"); + } else if (pvols.length === 1) { + remove_excuse = _("Last 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 = _("Not enough free space"); + } else { + remove_action = pvol_remove; + } + + register_crossref({ + key: vgroup, + card: pv_card, + actions: [ + { + title: _("Remove"), + action: remove_action, + excuse: remove_excuse, + }, + ], + size: , + }); + } + + return pv_card; +} + +export const LVM2PhysicalVolumeCard = ({ card, backing_block, content_block }) => { + const block_pvol = client.blocks_pvol[content_block.path]; + const vgroup = block_pvol && client.vgroups[block_pvol.VolumeGroup]; + + return ( + + + + + {vgroup + ? + : "-" + } + + + { block_pvol && + + + + } + + + ); +}; diff --git a/pkg/storaged/lvm2/thin-pool-logical-volume.jsx b/pkg/storaged/lvm2/thin-pool-logical-volume.jsx new file mode 100644 index 000000000000..0154ea731c70 --- /dev/null +++ b/pkg/storaged/lvm2/thin-pool-logical-volume.jsx @@ -0,0 +1,149 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { StorageCard, StorageDescription, ChildrenTable, new_page, new_card } from "../pages.jsx"; +import { fmt_size, validate_lvm2_name } from "../utils.js"; +import { dialog_open, TextInput, SizeSlider } from "../dialog.jsx"; +import { StorageLink } from "../storage-controls.jsx"; +import { grow_dialog } from "../block/resize.jsx"; + +import { next_default_logical_volume_name } from "./utils.jsx"; +import { lvol_rename, lvol_delete } from "./block-logical-volume.jsx"; +import { make_lvm2_logical_volume_page } from "./volume-group.jsx"; + +const _ = cockpit.gettext; + +export function make_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 pool_card = make_lvm2_thin_pool_card(null, vgroup, lvol); + + const thin_vols_card = new_card({ + title: _("Thinly provisioned LVM2 logical volumes"), + next: pool_card, + page_location: ["vg", vgroup.Name, lvol.Name], + page_name: lvol.Name, + page_size: lvol.Size, + component: LVM2ThinPoolLogicalVolumeCard, + props: { vgroup, lvol }, + actions: [ + { + title: _("Create new thinly provisioned logical volume"), + action: create_thin, + tag: "pool", + }, + ] + }); + + const p = new_page(parent, thin_vols_card); + + client.lvols_pool_members[lvol.path].forEach(member_lvol => { + make_lvm2_logical_volume_page(p, vgroup, member_lvol); + }); +} + +function make_lvm2_thin_pool_card(next, vgroup, lvol) { + let grow_excuse = null; + if (vgroup.FreeSize == 0) + grow_excuse = _("Not enough space"); + + const card = new_card({ + title: _("Pool for thinly provisioned LVM2 logical volumes"), + next, + component: LVM2ThinPoolCard, + props: { vgroup, lvol }, + actions: [ + { + title: _("Grow"), + action: () => grow_dialog(client, lvol, { }), + excuse: grow_excuse, + }, + { + title: _("Delete"), + action: () => lvol_delete(lvol, card), + danger: true, + }, + ], + }); + return card; +} + +function perc(ratio) { + return (ratio * 100).toFixed(0) + "%"; +} + +export const LVM2ThinPoolLogicalVolumeCard = ({ card, vgroup, lvol }) => { + return ( + + + + + ); +}; + +export const LVM2ThinPoolCard = ({ card, vgroup, lvol }) => { + return ( + + + + lvol_rename(lvol)}> + {_("edit")} + } /> + + + + + + ); +}; diff --git a/pkg/storaged/lvm2/unsupported-logical-volume.jsx b/pkg/storaged/lvm2/unsupported-logical-volume.jsx new file mode 100644 index 000000000000..8c3b31abc4a0 --- /dev/null +++ b/pkg/storaged/lvm2/unsupported-logical-volume.jsx @@ -0,0 +1,68 @@ +/* + * 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 { + StorageCard, new_page, new_card +} from "../pages.jsx"; + +import { lvol_delete } from "./block-logical-volume.jsx"; + +const _ = cockpit.gettext; + +export function make_unsupported_logical_volume_page(parent, vgroup, lvol, next_card) { + const unsupported_card = new_card({ + title: _("Unsupported logical volume"), + next: next_card, + page_location: ["vg", vgroup.Name, lvol.Name], + page_name: lvol.Name, + page_size: lvol.Size, + component: LVM2UnsupportedLogicalVolumeCard, + props: { vgroup, lvol }, + actions: [ + { title: _("Deactivate"), action: () => lvol.Deactivate({}) }, + { title: _("Delete"), action: () => lvol_delete(lvol, unsupported_card), danger: true }, + ] + }); + + // FIXME: it would be nice to log unsupported volumes with + // "console.error" so that our tests will detect them more + // readily. Unfortunately, when a logical volume gets activated, + // its block device will only appear on D-Bus a little while + // later, and the logical volume is thus considered unsupported + // for the little while. + // + // This also leads to potential flicker in the UI, so it would be + // nice to remove this intermediate state also for that reason. + + new_page(parent, unsupported_card); +} + +const LVM2UnsupportedLogicalVolumeCard = ({ card, vgroup, lvol }) => { + return ( + + +

{_("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/lvm2/utils.jsx b/pkg/storaged/lvm2/utils.jsx new file mode 100644 index 000000000000..ca0e2d7c2618 --- /dev/null +++ b/pkg/storaged/lvm2/utils.jsx @@ -0,0 +1,60 @@ +/* + * 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 { get_block_link_parts } from "../utils.js"; + +export function check_partial_lvols(client, path, enter_warning) { + if (client.lvols_status[path] && client.lvols_status[path] != "") { + enter_warning(path, { + warning: "partial-lvol", + danger: client.lvols_status[path] != "degraded" + }); + } +} + +export function pvs_to_spaces(client, pvs) { + return pvs.map(pvol => { + const block = client.blocks[pvol.path]; + const parts = get_block_link_parts(client, pvol.path); + const text = cockpit.format(parts.format, parts.link); + return { type: 'block', block, size: pvol.FreeSize, desc: text, pvol }; + }); +} + +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++) { + if (lvols[i].Name == name) + return lvols[i]; + } + return null; + } + + let name; + for (let i = 0; i < 1000; i++) { + name = prefix + i.toFixed(); + if (!find_lvol(name)) + break; + } + + return name; +} diff --git a/pkg/storaged/lvm2/vdo-pool.jsx b/pkg/storaged/lvm2/vdo-pool.jsx new file mode 100644 index 000000000000..03f8011bbecb --- /dev/null +++ b/pkg/storaged/lvm2/vdo-pool.jsx @@ -0,0 +1,95 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { StorageOnOff } from "../storage-controls.jsx"; + +import { grow_dialog } from "../block/resize.jsx"; +import { StorageCard, StorageDescription, new_card } from "../pages.jsx"; +import { fmt_size } from "../utils.js"; + +const _ = cockpit.gettext; + +export function make_vdo_pool_card(next, vgroup, lvol) { + const vdo_iface = client.vdo_vols[lvol.path]; + const vdo_pool_vol = client.lvols[vdo_iface.VDOPool]; + + if (!vdo_pool_vol) + return null; + + return new_card({ + title: _("LVM2 VDO pool"), + next, + component: LVM2VDOPoolCard, + props: { vgroup, lvol, vdo_iface, vdo_pool_vol }, + actions: [ + { + title: _("Grow"), + action: () => grow_dialog(client, vdo_pool_vol, { }), + } + ], + }); +} + +const LVM2VDOPoolCard = ({ card, vgroup, lvol, vdo_iface, vdo_pool_vol }) => { + function toggle_compression() { + const new_state = !vdo_iface.Compression; + return vdo_iface.EnableCompression(new_state, {}) + .then(() => client.wait_for(() => vdo_iface.Compression === new_state)); + } + + function toggle_deduplication() { + const new_state = !vdo_iface.Deduplication; + return vdo_iface.EnableDeduplication(new_state, {}) + .then(() => client.wait_for(() => vdo_iface.Deduplication === new_state)); + } + + function perc(ratio) { + return (ratio * 100).toFixed(0) + "%"; + } + + const used_pct = perc(vdo_iface.UsedSize / vdo_pool_vol.Size); + + return ( + + + + + + + {fmt_size(vdo_iface.UsedSize)} ({used_pct}) + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/lvm2/volume-group.jsx b/pkg/storaged/lvm2/volume-group.jsx new file mode 100644 index 000000000000..3de6bc550e53 --- /dev/null +++ b/pkg/storaged/lvm2/volume-group.jsx @@ -0,0 +1,393 @@ +/* + * 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 { CardHeader, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { useObject } from "hooks"; + +import { VolumeIcon } from "../icons/gnome-icons.jsx"; +import { StorageButton, StorageLink } from "../storage-controls.jsx"; +import { + StorageCard, StorageDescription, ChildrenTable, PageTable, new_page, new_card, get_crossrefs, + navigate_to_new_card_location, navigate_away_from_card +} from "../pages.jsx"; +import { + 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 { create_logical_volume } from "./create-logical-volume-dialog.jsx"; +import { make_block_logical_volume_card } from "./block-logical-volume.jsx"; +import { make_vdo_pool_card } from "./vdo-pool.jsx"; +import { make_thin_pool_logical_volume_page } from "./thin-pool-logical-volume.jsx"; +import { make_inactive_logical_volume_page } from "./inactive-logical-volume.jsx"; +import { make_unsupported_logical_volume_page } from "./unsupported-logical-volume.jsx"; +import { make_block_page } from "../block/create-pages.jsx"; + +const _ = cockpit.gettext; + +function vgroup_rename(client, vgroup, card) { + dialog_open({ + Title: _("Rename volume group"), + Fields: [ + TextInput("name", _("Name"), + { + value: vgroup.Name, + validate: validate_lvm2_name + }) + ], + Action: { + Title: _("Rename"), + action: async function (vals) { + await vgroup.Rename(vals.name, { }); + navigate_to_new_card_location(card, ["vg", vals.name]); + } + } + }); +} + +function vgroup_delete(client, vgroup, card) { + const usage = get_active_usage(client, vgroup.path, _("delete")); + + if (usage.Blocking) { + dialog_open({ + Title: cockpit.format(_("$0 is in use"), + vgroup.Name), + Body: BlockingMessage(usage) + }); + return; + } + + dialog_open({ + Title: cockpit.format(_("Permanently delete $0?"), vgroup.Name), + Teardown: TeardownMessage(usage), + Action: { + Danger: _("Deleting erases all data on a volume group."), + Title: _("Delete"), + disable_on_error: usage.Teardown, + action: async function () { + await teardown_active_usage(client, usage); + await vgroup.Delete(true, { 'tear-down': { t: 'b', v: true } }); + await reload_systemd(); + navigate_away_from_card(card); + } + }, + 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) }; +} + +function make_generic_logical_volume_card(next, vgroup, lvol) { + let result = next; + if (client.vdo_vols[lvol.path]) + result = make_vdo_pool_card(result, vgroup, lvol); + return result; +} + +export function make_lvm2_logical_volume_page(parent, vgroup, lvol) { + const generic_card = make_generic_logical_volume_card(null, vgroup, lvol); + + if (lvol.Type == "pool") { + make_thin_pool_logical_volume_page(parent, vgroup, lvol); + } else { + const block = client.lvols_block[lvol.path]; + if (block) { + const lv_card = make_block_logical_volume_card(generic_card, vgroup, lvol, block); + make_block_page(parent, block, lv_card); + } else { + // If we can't find the block for a active volume, UDisks2 + // or something below is probably misbehaving, and we show + // it as "unsupported". + if (lvol.Active) { + make_unsupported_logical_volume_page(parent, vgroup, lvol, generic_card); + } else { + make_inactive_logical_volume_page(parent, vgroup, lvol, generic_card); + } + } + } +} + +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 => { + // We ignore volumes in a thin pool; they appear as children + // of their pool. + // + // We ignore old-style snapshots because Cockpit would need to + // treat them specially, and we haven't bothered to write the + // code for that. + // + // We ignore vdo pools; they appear as a card for their + // single contained logical volume. + // + if (lvol.ThinPool == "/" && lvol.Origin == "/" && !isVDOPool(lvol)) + make_lvm2_logical_volume_page(parent, vgroup, lvol); + }); +} + +function add_disk(vgroup) { + 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; + } + + 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, {})))); + } + } + }); +} + +export function make_lvm2_volume_group_page(parent, vgroup) { + const has_missing_pvs = vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0; + + let lvol_excuse = null; + if (has_missing_pvs) + lvol_excuse = _("Volume group is missing physical volumes"); + else if (vgroup.FreeSize == 0) + lvol_excuse = _("No free space"); + + const vgroup_card = new_card({ + title: _("LVM2 volume group"), + next: null, + page_location: ["vg", vgroup.Name], + page_name: vgroup.Name, + page_icon: VolumeIcon, + page_size: vgroup.Size, + job_path: vgroup.path, + component: LVM2VolumeGroupCard, + props: { vgroup }, + actions: [ + { + title: _("Add physical volume"), + action: () => add_disk(vgroup), + tag: "pvols", + }, + { + title: _("Delete group"), + action: () => vgroup_delete(client, vgroup, vgroup_card), + danger: true, + tag: "group", + }, + ], + }); + + const lvols_card = new_card({ + title: _("LVM2 logical volumes"), + next: vgroup_card, + has_warning: has_missing_pvs, + component: LVM2LogicalVolumesCard, + props: { vgroup }, + actions: [ + { + title: _("Create new logical volume"), + action: () => create_logical_volume(client, vgroup), + excuse: lvol_excuse, + tag: "group", + }, + ], + }); + + const vgroup_page = new_page(parent, lvols_card); + 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 LVM2LogicalVolumesCard = ({ card, vgroup }) => { + return ( + + + + + + ); +}; + +const LVM2VolumeGroupCard = ({ card, vgroup }) => { + const has_missing_pvs = vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0; + + 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) + ] + }); + } + + const alerts = []; + if (has_missing_pvs) + alerts.push( + {_("Dismiss")}} + title={_("This volume group is missing some physical volumes.")}> + {vgroup.MissingPhysicalVolumes.map(uuid =>
{uuid}
)} +
); + + return ( + + + + vgroup_rename(client, vgroup, card)} + excuse={has_missing_pvs && _("A volume group with missing physical volumes can not be renamed.")}> + {_("edit")} + } /> + + + + + {_("Physical volumes")} + + + + + ); +}; diff --git a/pkg/storaged/lvol-tabs.jsx b/pkg/storaged/lvol-tabs.jsx deleted file mode 100644 index 4c42368b1953..000000000000 --- a/pkg/storaged/lvol-tabs.jsx +++ /dev/null @@ -1,412 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2016 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 * as utils from "./utils.js"; - -import React from "react"; -import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; -import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; -import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; -import { ExclamationTriangleIcon, ExclamationCircleIcon } from "@patternfly/react-icons"; -import { StorageButton, StorageLink, StorageOnOff } from "./storage-controls.jsx"; -import { dialog_open, TextInput } from "./dialog.jsx"; -import { get_resize_info, grow_dialog, shrink_dialog } from "./resize.jsx"; -import { fmt_size } from "./utils.js"; - -const _ = cockpit.gettext; - -export function check_partial_lvols(client, path, enter_warning) { - if (client.lvols_status[path] && client.lvols_status[path] != "") { - enter_warning(path, { - warning: "partial-lvol", - danger: client.lvols_status[path] != "degraded" - }); - } -} - -function lvol_rename(lvol) { - dialog_open({ - Title: _("Rename logical volume"), - Fields: [ - TextInput("name", _("Name"), - { value: lvol.Name }) - ], - Action: { - Title: _("Rename"), - action: function (vals) { - return lvol.Rename(vals.name, { }); - } - } - }); -} - -const StructureDescription = ({ client, lvol }) => { - const vgroup = client.vgroups[lvol.VolumeGroup]; - const pvs = (vgroup && client.vgroups_pvols[vgroup.path]) || []; - - if (!lvol.Structure || pvs.length <= 1) - return null; - - let status = null; - const status_code = client.lvols_status[lvol.path]; - if (status_code == "partial") { - status = _("This logical volume has lost some of its physical volumes and can no longer be used. You need to delete it and create a new one to take its place."); - } else if (status_code == "degraded") { - status = _("This logical volume has lost some of its physical volumes but has not lost any data yet. You should repair it to restore its original redundancy."); - } else if (status_code == "degraded-maybe-partial") { - status = _("This logical volume has lost some of its physical volumes but might not have lost any data yet. You might be able to repair it."); - } - - function nice_block_name(block) { - return utils.block_name(client.blocks[block.CryptoBackingDevice] || block); - } - - function pvs_box(used, block_path) { - if (block_path != "/") { - const block = client.blocks[block_path]; - return
-
- {block ? nice_block_name(block).replace("/dev/", "") : "???"} -
-
{fmt_size(used)}
-
; - } else { - return
-
- { status_code == "degraded" - ? - : - } -
-
{fmt_size(used)}
-
; - } - } - - if (lvol.Layout == "linear") { - const pvs = client.lvols_stripe_summary[lvol.path]; - if (!pvs) - return null; - - const stripe = Object.keys(pvs).map((path, i) => - - {pvs_box(pvs[path], path)} - ); - - return ( - - {_("Physical volumes")} - - - {stripe} - - {status} - - ); - } - - function stripe_box(used, block_path) { - if (block_path != "/") { - const block = client.blocks[block_path]; - return
-
- {block ? nice_block_name(block).replace("/dev/", "") : "???"} -
-
{fmt_size(used)}
-
; - } else { - return
-
- { status_code == "degraded" - ? - : - } -
-
{fmt_size(used)}
-
; - } - } - - if (lvol.Layout == "mirror" || lvol.Layout.indexOf("raid") == 0) { - const summary = client.lvols_stripe_summary[lvol.path]; - if (!summary) - return null; - - const stripes = summary.map((pvs, i) => - - {Object.keys(pvs).map(path => stripe_box(pvs[path], path))} - ); - - return ( - <> - - {_("Stripes")} - - {stripes} - {status} - {lvol.SyncRatio != 1.0 - ?
{cockpit.format(_("$0 synchronized"), lvol.SyncRatio * 100 + "%")}
- : null} -
-
- ); - } - - return null; -}; - -export class BlockVolTab extends React.Component { - render() { - const self = this; - const client = self.props.client; - const lvol = self.props.lvol; - const pool = client.lvols[lvol.ThinPool]; - const block = client.lvols_block[lvol.path]; - const vgroup = client.vgroups[lvol.VolumeGroup]; - const unused_space_warning = self.props.warnings.find(w => w.warning == "unused-space"); - const unused_space = !!unused_space_warning; - - function rename() { - lvol_rename(lvol); - } - - 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 = this.props.lvol.Layout; - - return ( -
- - - {_("Name")} - - - {this.props.lvol.Name} - {_("edit")} - - - - { (layout && layout != "linear") && - - {_("Layout")} - - - {layout_desc[layout] || layout} - - - - } - - { !unused_space && - - {_("Size")} - - {utils.fmt_size(this.props.lvol.Size)} -
- {_("Shrink")} - {_("Grow")} -
-
-
- } -
- { unused_space && - <> -
- - {cockpit.format(_("Volume size is $0. Content size is $1."), - utils.fmt_size(unused_space_warning.volume_size), - utils.fmt_size(unused_space_warning.content_size))} -
- {_("Shrink volume")} - {_("Grow content")} -
-
- - } -
- ); - } -} - -function perc(ratio) { - return (ratio * 100).toFixed(0) + "%"; -} - -export class PoolVolTab extends React.Component { - render() { - const self = this; - const client = self.props.client; - const lvol = self.props.lvol; - const vgroup = client.vgroups[lvol.VolumeGroup]; - - function rename() { - lvol_rename(self.props.lvol); - } - - function grow() { - grow_dialog(self.props.client, self.props.lvol, { }); - } - - let grow_excuse = null; - if (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.")} -
- ); - } - - return ( - - - {_("Name")} - - - {this.props.lvol.Name} - {_("edit")} - - - - - - {_("Size")} - - {utils.fmt_size(this.props.lvol.Size)} - - {_("Grow")} - - - - - - {_("Data used")} - {perc(this.props.lvol.DataAllocatedRatio)} - - - - {_("Metadata used")} - {perc(this.props.lvol.MetadataAllocatedRatio)} - - - ); - } -} - -export const VDOPoolTab = ({ client, lvol }) => { - const vdo_iface = client.vdo_vols[lvol.path]; - const vdo_pool_vol = client.lvols[vdo_iface.VDOPool]; - - if (!vdo_pool_vol) - return null; - - function grow() { - grow_dialog(client, vdo_pool_vol, { }); - } - - function toggle_compression() { - const new_state = !vdo_iface.Compression; - return vdo_iface.EnableCompression(new_state, {}) - .then(() => client.wait_for(() => vdo_iface.Compression === new_state)); - } - - function toggle_deduplication() { - const new_state = !vdo_iface.Deduplication; - return vdo_iface.EnableDeduplication(new_state, {}) - .then(() => client.wait_for(() => vdo_iface.Deduplication === new_state)); - } - - const used_pct = perc(vdo_iface.UsedSize / vdo_pool_vol.Size); - - return ( - - - {_("Name")} - {vdo_pool_vol.Name} - - - - {_("Size")} - - {utils.fmt_size(vdo_pool_vol.Size)} - - {_("Grow")} - - - - - - {_("Data used")} - {utils.fmt_size(vdo_iface.UsedSize)} ({used_pct}) - - - - {_("Metadata used")} - {perc(lvol.MetadataAllocatedRatio)} - - - - {_("Compression")} - - - - - - - {_("Deduplication")} - - - - - - ); -}; diff --git a/pkg/storaged/mdraid-details.jsx b/pkg/storaged/mdraid-details.jsx deleted file mode 100644 index 88dd7e8e66e2..000000000000 --- a/pkg/storaged/mdraid-details.jsx +++ /dev/null @@ -1,366 +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 cockpit from "cockpit"; -import React from "react"; -import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; -import { Card, CardBody, CardHeader, CardTitle } 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 { MinusIcon, PlusIcon } from "@patternfly/react-icons"; -import * as utils from "./utils.js"; -import { StdDetailsLayout } from "./details.jsx"; -import { SidePanel } from "./side-panel.jsx"; -import { Block } from "./content-views.jsx"; -import { StorageButton } from "./storage-controls.jsx"; -import { - dialog_open, SelectSpaces, BlockingMessage, TeardownMessage, - init_active_usage_processes -} from "./dialog.jsx"; - -const _ = cockpit.gettext; - -class MDRaidSidebar extends React.Component { - render() { - const self = this; - const client = self.props.client; - const mdraid = self.props.mdraid; - - 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: utils.get_available_spaces(client).filter(filter_inside_mdraid) - }) - ], - Action: { - Title: _("Add"), - action: function(vals) { - return utils.prepare_available_spaces(client, vals.disks).then(paths => - Promise.all(paths.map(p => mdraid.AddDevice(p, {}).then(() => rescan(p))))); - } - } - }); - } - - const members = client.mdraids_members[mdraid.path] || []; - const dynamic_members = (mdraid.Level != "raid0"); - - 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; - - function render_member(block) { - const active_state = mdraid.ActiveDevices.find(as => as[0] == block.path); - - function 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."); - - function remove() { - return mdraid.RemoveDevice(block.path, { wipe: { t: 'b', v: true } }); - } - - let action = null; - if (dynamic_members) - action = ( - - - ); - - return { client, block, actions: action, detail: states, key: block.path }; - } - - let add_excuse = false; - if (!running) - add_excuse = _("The RAID device must be running in order to add spare disks."); - - let action = null; - if (dynamic_members) - action = ( - - - ); - - return ; - } -} - -function mdraid_start(client, mdraid) { - return mdraid.Start({ "start-degraded": { t: 'b', v: true } }); -} - -function mdraid_stop(client, mdraid) { - const block = client.mdraids_block[mdraid.path]; - const usage = utils.get_active_usage(client, block ? block.path : "", _("stop")); - - if (usage.Blocking) { - dialog_open({ - Title: cockpit.format(_("$0 is in use"), utils.mdraid_name(mdraid)), - Body: BlockingMessage(usage), - }); - return; - } - - if (usage.Teardown) { - dialog_open({ - Title: cockpit.format(_("Confirm stopping of $0"), - utils.mdraid_name(mdraid)), - Teardown: TeardownMessage(usage), - Action: { - Title: _("Stop device"), - disable_on_error: true, - action: function () { - return utils.teardown_active_usage(client, usage) - .then(function () { - return mdraid.Stop({}); - }); - } - }, - Inits: [ - init_active_usage_processes(client, usage) - ] - }); - return; - } - - return mdraid.Stop({}); -} - -function mdraid_delete(client, mdraid) { - const block = client.mdraids_block[mdraid.path]; - const location = cockpit.location; - - function delete_() { - if (mdraid.Delete) - return mdraid.Delete({ 'tear-down': { t: 'b', v: true } }).then(utils.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 = utils.get_active_usage(client, block ? block.path : "", _("delete")); - - if (usage.Blocking) { - dialog_open({ - Title: cockpit.format(_("$0 is in use"), utils.mdraid_name(mdraid)), - Body: BlockingMessage(usage) - }); - return; - } - - dialog_open({ - Title: cockpit.format(_("Permanently delete $0?"), utils.mdraid_name(mdraid)), - Teardown: TeardownMessage(usage), - Action: { - Title: _("Delete"), - Danger: _("Deleting erases all data on a RAID device."), - disable_on_error: usage.Teardown, - action: function () { - return utils.teardown_active_usage(client, usage) - .then(delete_) - .then(function () { - location.go('/'); - }); - } - }, - Inits: [ - init_active_usage_processes(client, usage) - ] - }); -} - -export class MDRaidDetails extends React.Component { - render() { - const client = this.props.client; - const mdraid = this.props.mdraid; - const block = mdraid && client.mdraids_block[mdraid.path]; - - 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"), utils.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(utils.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 && utils.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; - - const header = ( - - - { running - ? mdraid_stop(client, mdraid)}>{_("Stop")} - : mdraid_start(client, mdraid)}>{_("Start")} - } - { "\n" } - mdraid_delete(client, mdraid)}>{_("Delete")} - , - }}> - { cockpit.format(_("RAID device $0"), utils.mdraid_name(mdraid)) } - - - - - {_("storage", "Device")} - { block ? utils.decode_filename(block.PreferredDevice) : "-" } - - - - {_("storage", "UUID")} - { mdraid.UUID } - - - - {_("storage", "Capacity")} - { utils.fmt_size_long(mdraid.Size) } - - - - {_("storage", "RAID level")} - { level } - - - {_("storage", "State")} - { running ? _("Running") : _("Not running") } - - - - - ); - - const sidebar = ; - - const content = ; - - return ; - } -} diff --git a/pkg/storaged/mdraids-panel.jsx b/pkg/storaged/mdraid/create-dialog.jsx similarity index 83% rename from pkg/storaged/mdraids-panel.jsx rename to pkg/storaged/mdraid/create-dialog.jsx index 5b7108a76a63..19f049525cdb 100644 --- a/pkg/storaged/mdraids-panel.jsx +++ b/pkg/storaged/mdraid/create-dialog.jsx @@ -1,7 +1,7 @@ /* * This file is part of Cockpit. * - * Copyright (C) 2017 Red Hat, Inc. + * 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 @@ -18,41 +18,14 @@ */ import cockpit from "cockpit"; +import client from "../client"; -import { - fmt_size, mdraid_name, block_name, validate_mdraid_name, - get_available_spaces, prepare_available_spaces -} from "./utils.js"; -import { dialog_open, TextInput, SelectOne, SelectSpaces } from "./dialog.jsx"; +import { mdraid_name, validate_mdraid_name, get_available_spaces, prepare_available_spaces } from "../utils.js"; +import { dialog_open, TextInput, SelectOne, SelectSpaces } from "../dialog.jsx"; const _ = cockpit.gettext; -function mdraid_row(client, path) { - const mdraid = client.mdraids[path]; - const block = client.mdraids_block[path]; - - return { - client, - kind: "array", - name: mdraid_name(mdraid), - devname: block && block_name(block), - detail: fmt_size(mdraid.Size) + " " + _("RAID device"), - job_path: path, - key: path, - go: () => cockpit.location.go(["mdraid", mdraid.UUID]) - }; -} - -export function mdraid_rows(client, options) { - function cmp_mdraid(path_a, path_b) { - return mdraid_name(client.mdraids[path_a]).localeCompare(mdraid_name(client.mdraids[path_b])); - } - - return Object.keys(client.mdraids).sort(cmp_mdraid) - .map(p => mdraid_row(client, p)); -} - -export function create_mdraid(client) { +export function create_mdraid() { function mdraid_exists(name) { for (const p in client.mdraids) { if (mdraid_name(client.mdraids[p]) == name) diff --git a/pkg/storaged/mdraid/mdraid-disk.jsx b/pkg/storaged/mdraid/mdraid-disk.jsx new file mode 100644 index 000000000000..ada102336118 --- /dev/null +++ b/pkg/storaged/mdraid/mdraid-disk.jsx @@ -0,0 +1,140 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2023 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +import cockpit from "cockpit"; +import React from "react"; +import client from "../client"; + +import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; +import { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { StorageCard, StorageDescription, new_card, register_crossref } from "../pages.jsx"; +import { format_dialog } from "../block/format-dialog.jsx"; +import { block_short_name, fmt_size, mdraid_name } from "../utils.js"; +import { std_lock_action } from "../crypto/actions.jsx"; + +const _ = cockpit.gettext; + +export function make_mdraid_disk_card(next, backing_block, content_block) { + const mdraid = client.mdraids[content_block.MDRaidMember]; + const mdraid_block = mdraid && client.mdraids_block[mdraid.path]; + + const disk_card = new_card({ + title: _("MDRAID disk"), + next, + location: mdraid_block ? block_short_name(mdraid_block) : (mdraid ? mdraid_name(mdraid) : null), + component: MDRaidDiskCard, + 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 = null; + if (!running) + remove_excuse = _("The MDRAID device must be running"); + else if ((is_in_sync && n_recovering > 0) || is_recovering) + remove_excuse = _("MDRAID device is recovering"); + else if (is_in_sync && n_spares < 1) + remove_excuse = _("Need a spare disk"); + else if (members.length <= 1) + remove_excuse = _("Last disk can not 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, + card: disk_card, + actions: [ + remove_action + ], + size: fmt_size(content_block.Size), + extra: states, + }); + } + + return disk_card; +} + +export const MDRaidDiskCard = ({ card, backing_block, content_block, mdraid }) => { + return ( + + + + + {mdraid + ? + : "-" + } + + + + + ); +}; diff --git a/pkg/storaged/mdraid/mdraid.jsx b/pkg/storaged/mdraid/mdraid.jsx new file mode 100644 index 000000000000..6b09e2b9a44e --- /dev/null +++ b/pkg/storaged/mdraid/mdraid.jsx @@ -0,0 +1,306 @@ +/* + * 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 { CardHeader, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { VolumeIcon } from "../icons/gnome-icons.jsx"; +import { StorageButton, StorageLink } from "../storage-controls.jsx"; +import { StorageCard, StorageDescription, PageTable, new_page, new_card, get_crossrefs, navigate_away_from_card } from "../pages.jsx"; +import { make_block_page } from "../block/create-pages.jsx"; +import { + block_short_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 { partitionable_block_actions } from "../partitions/actions.jsx"; + +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, card) { + 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 MDRAID device."), + action: async function () { + await teardown_active_usage(client, usage); + await delete_(); + navigate_away_from_card(card); + } + }, + 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; + + // "Stop" is only in the card, to discourage stopping. + + if (!running) + return { title: _("Start"), action: () => mdraid_start(mdraid), tag: "device" }; + else + return null; +} + +function add_disk(mdraid) { + 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({ })); + } + + 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))))); + } + } + }); +} + +function missing_bitmap(mdraid) { + return (mdraid.Level != "raid0" && + client.mdraids_members[mdraid.path].some(m => m.Size > 100 * 1024 * 1024 * 1024) && + mdraid.BitmapLocation && decode_filename(mdraid.BitmapLocation) == "none"); +} + +export function make_mdraid_page(parent, mdraid) { + const block = client.mdraids_block[mdraid.path]; + + let add_excuse = false; + if (!block) + add_excuse = _("MDRAID device must be running"); + + const mdraid_card = new_card({ + title: _("MDRAID device"), + next: null, + page_location: ["mdraid", mdraid.UUID], + page_name: block ? block_short_name(block) : mdraid_name(mdraid), + page_icon: VolumeIcon, + page_size: mdraid.Size, + type_extra: !block && _("stopped"), + id_extra: block && _("MDRAID device"), + for_summary: true, + has_warning: mdraid.Degraded > 0 || missing_bitmap(mdraid), + job_path: mdraid.path, + component: MDRaidCard, + props: { mdraid, block }, + actions: [ + start_stop_action(mdraid), + (mdraid.Level != "raid0" && + { + title: _("Add disk"), + action: () => add_disk(mdraid), + excuse: add_excuse, + tag: "disks", + }), + ].concat( + (block ? partitionable_block_actions(block, "device") : []).concat( + [ + { + title: _("Delete"), + action: () => mdraid_delete(mdraid, block, mdraid_card), + danger: true, + }, + ])), + }); + + if (!block) { + new_page(parent, mdraid_card); + } else + make_block_page(parent, block, mdraid_card); +} + +const MDRaidCard = ({ card, 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)); + + const alerts = []; + if (mdraid.Degraded > 0) { + const text = cockpit.format( + cockpit.ngettext("$0 disk is missing", "$0 disks are missing", mdraid.Degraded), + mdraid.Degraded + ); + alerts.push( + + {text} + ); + } + + function fix_bitmap() { + return mdraid.SetBitmapLocation(encode_filename("internal"), { }); + } + + if (missing_bitmap(mdraid)) { + alerts.push( + +
+ {_("Add a bitmap")} +
+
); + } + + return ( + + + + + + mdraid_stop(mdraid)}> + {_("Stop")} + } /> + + + + + + {_("Disks")} + + + + + ); +}; 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/nfs-panel.jsx b/pkg/storaged/nfs-panel.jsx deleted file mode 100644 index ccce2e691a5e..000000000000 --- a/pkg/storaged/nfs-panel.jsx +++ /dev/null @@ -1,107 +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 cockpit from "cockpit"; -import React from "react"; -import { SortByDirection } from '@patternfly/react-table'; -import { PlusIcon } from '@patternfly/react-icons'; - -import { ListingTable } from "cockpit-components-table.jsx"; -import { StorageButton, StorageUsageBar } from "./storage-controls.jsx"; -import { nfs_fstab_dialog } from "./nfs-details.jsx"; -import { OptionalPanel } from "./optional-panel.jsx"; - -const _ = cockpit.gettext; - -export class NFSPanel extends React.Component { - render() { - const client = this.props.client; - - function make_nfs_mount(entry) { - let fsys_size; - if (entry.mounted) - fsys_size = client.nfs.get_fsys_size(entry); - - const server = entry.fields[0].split(":")[0]; - const remote_dir = entry.fields[0].split(":")[1]; - - return { - props: { entry, key: entry.fields[1] }, - columns: [ - { title: server + " " + remote_dir }, - { title: entry.fields[1] }, - { - title: entry.mounted - ? - : _("Not mounted") - } - ] - }; - } - - const mounts = client.nfs.entries.map(make_nfs_mount); - - function add() { - nfs_fstab_dialog(client, null); - } - - const actions = ( - - - - ); - - const nfs_feature = { - is_enabled: () => client.features.nfs, - package: client.get_config("nfs_client_package", false), - enable: () => { - client.features.nfs = true; - client.nfs.start(); - } - }; - - function onRowClick(event, row) { - if (!event || event.button !== 0) - return; - cockpit.location.go(["nfs", row.props.entry.fields[0], row.props.entry.fields[1]]); - } - - return ( - - - - ); - } -} diff --git a/pkg/storaged/nfs-mounts.py b/pkg/storaged/nfs/nfs-mounts.py similarity index 100% rename from pkg/storaged/nfs-mounts.py rename to pkg/storaged/nfs/nfs-mounts.py diff --git a/pkg/storaged/nfs-details.jsx b/pkg/storaged/nfs/nfs.jsx similarity index 64% rename from pkg/storaged/nfs-details.jsx rename to pkg/storaged/nfs/nfs.jsx index 15d082a6e801..f5552f5c8a6f 100644 --- a/pkg/storaged/nfs-details.jsx +++ b/pkg/storaged/nfs/nfs.jsx @@ -1,7 +1,7 @@ /* * This file is part of Cockpit. * - * Copyright (C) 2018 Red Hat, Inc. + * 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 @@ -19,21 +19,25 @@ import cockpit from "cockpit"; import React from "react"; +import client from "../client"; + import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; -import { Card, CardBody, CardHeader, CardTitle } 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 { CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DescriptionList } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; + +import { NetworkIcon } from "../icons/gnome-icons.jsx"; import { dialog_open, TextInput, ComboBox, CheckBoxes, StopProcessesMessage, stop_processes_danger_message -} from "./dialog.jsx"; +} from "../dialog.jsx"; -import { StdDetailsLayout } from "./details.jsx"; -import { StorageButton, StorageUsageBar } from "./storage-controls.jsx"; -import { parse_options, unparse_options, extract_option } from "./utils.js"; +import { StorageUsageBar } from "../storage-controls.jsx"; +import { StorageCard, StorageDescription, new_page, new_card, navigate_to_new_card_location, navigate_away_from_card } 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 nfs_busy_dialog(dialog_title, entry, error, action_title, action) { function show(users) { if (users.length === 0) { dialog_open({ @@ -78,7 +82,7 @@ function get_exported_directories(server) { }); } -export function nfs_fstab_dialog(client, entry) { +export function nfs_fstab_dialog(entry, card) { 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"); @@ -178,21 +182,19 @@ export function nfs_fstab_dialog(client, entry) { }, Action: { Title: entry ? _("Save") : _("Add"), - action: function (vals) { - const location = cockpit.location; - const fields = [vals.server + ":" + vals.remote, + action: async function (vals) { + const fields = [ + vals.server + ":" + vals.remote, vals.dir, entry ? entry.fields[2] : "nfs", - mounting_options(vals) || "defaults"]; + 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]]); - }); + await client.nfs.update_entry(entry, fields); + if (entry.fields[0] != fields[0] || entry.fields[1] != fields[1]) + navigate_to_new_card_location(card, ["nfs", fields[0], fields[1]]); } else - return client.nfs.add_entry(fields); + await client.nfs.add_entry(fields); } } }); @@ -222,112 +224,106 @@ function checked(error_title, promise) { }); } -function mount(client, entry) { +function mount(entry) { checked("Could not mount the filesystem", client.nfs.mount_entry(entry)); } -function unmount(client, entry) { - const location = cockpit.location; +function unmount(entry, card) { client.nfs.unmount_entry(entry) .then(function () { if (!entry.fstab) - location.go("/"); + navigate_away_from_card(card); }) .catch(function (error) { - nfs_busy_dialog(client, - _("Unable to unmount filesystem"), + nfs_busy_dialog(_("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("/"); + navigate_away_from_card(card); }); }); }); } -function edit(client, entry) { - nfs_fstab_dialog(client, entry); +function edit(entry, card) { + nfs_fstab_dialog(entry, card); } -function remove(client, entry) { - const location = cockpit.location; +function remove(entry, card) { client.nfs.remove_entry(entry) .then(function () { - location.go("/"); + navigate_away_from_card(card); }) .catch(function (error) { - nfs_busy_dialog(client, - _("Unable to remove mount"), + nfs_busy_dialog(_("Unable to remove mount"), entry, error, _("Stop and remove"), function (users) { return client.nfs.stop_and_remove_entry(users, entry) .then(function () { - location.go("/"); + navigate_away_from_card(card); }); }); }); } -export class NFSDetails extends React.Component { - render() { - const client = this.props.client; - const entry = this.props.entry; - let fsys_size; - if (entry.mounted) - fsys_size = client.nfs.get_fsys_size(entry); - - const header = ( - - - { entry.mounted - ? unmount(client, entry)}>{_("Unmount")} - : mount(client, entry)}>{_("Mount")} - } - { "\n" } - { entry.fstab - ? [ - edit(client, entry)}>{_("Edit")}, - "\n", - remove(client, entry)} kind="danger">{_("Remove")} - ] - : null - } - , - }}> - {entry.fields[0]} - - - - - {_("Server")} - {entry.fields[0]} - +const NfsEntryUsageBar = ({ entry, not_mounted_text, short }) => { + if (entry.mounted) + return ; + else + return not_mounted_text; +}; - - {_("Mount point")} - {entry.fields[1]} - +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)"); - - {_("Size")} - - { entry.mounted - ? - : "--" - } - - - - - - ); + const nfs_card = new_card({ + title: _("NFS mount"), + location: mount_point, + next: null, + page_location: ["nfs", remote, local], + page_name: remote, + page_icon: NetworkIcon, + page_size: , + component: NfsCard, + props: { entry }, + actions: [ + (entry.mounted + ? { title: _("Unmount"), action: () => unmount(entry, nfs_card) } + : { title: _("Mount"), action: () => mount(entry) }), + (entry.fstab + ? { title: _("Edit"), action: () => edit(entry, nfs_card) } + : null), + (entry.fstab + ? { title: _("Remove"), action: () => remove(entry, nfs_card), danger: true } + : null), + ] + }); - return ; - } + new_page(parent, nfs_card); } + +const NfsCard = ({ card, entry }) => { + return ( + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/optional-panel.jsx b/pkg/storaged/optional-panel.jsx deleted file mode 100644 index fa45832fe0a5..000000000000 --- a/pkg/storaged/optional-panel.jsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2018 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, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js'; -import { EmptyState, EmptyStateBody, EmptyStateVariant } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js"; - -import { install_dialog } from "cockpit-components-install-dialog.jsx"; -import { StorageButton } from "./storage-controls.jsx"; - -const _ = cockpit.gettext; - -/* OptionalPanel - a panel that is only visible when a certain feature - has been detected. It can also install missing packages - to enable that feature. - - Properties: - - - client: The storage client - - title: Title of the panel - - actions: Buttons to show in the heading when the feature is enabled - - children: For the body of the panel when the feature is enabled - - - feature: The feature object, see below. - - not_installed_text: The text to show in the panel body when the feature is not enabled. - - install_title: The text in the button that lets the user enable the feature at run time. - - When the "feature" property is omitted (or false), the panel will always be shown. - Otherwise, the feature object determines what happens. It has the following fields: - - - is_enabled: A function that should return whether the feature is enabled. This - function is called during rendering and thus needs to be fast and synchronous. - - - package: The name of the package to install. If omitted or false, the feature - can not be enabled at run-time and the panel will be fully invisible - if not already enabled. - - - enable: A function that is called once support for the feature has been - successfully installed. Subsequent calls to "is_enabled" should return true. -*/ - -export class OptionalPanel extends React.Component { - constructor() { - super(); - this.state = { - just_installed: false, - }; - } - - render() { - const self = this; - const { - actions, className, id, title, - feature, not_installed_text, install_title - } = this.props; - - const feature_enabled = !feature || feature.is_enabled(); - const required_package = feature && feature.package; - - if (!feature_enabled && !(required_package && this.props.client.features.packagekit)) - return null; - - function install() { - install_dialog(required_package).then(() => { - feature.enable(); - self.setState({ just_installed: "just-installed" }); - window.setTimeout(() => { self.setState({ just_installed: "just-installed faded" }) }, - 4000); - }, - () => null /* ignore cancel */); - } - - let heading_right = null; - if (!feature_enabled) { - heading_right = {install_title}; - } else { - heading_right = ( - <> - { this.state.just_installed - ? {_("Support is installed.")} - : null - } - { actions } - - ); - } - - return ( - - - {title} - - - { feature_enabled - ? this.props.children - : - - {not_installed_text} - - - } - - - ); - } -} diff --git a/pkg/storaged/others-panel.jsx b/pkg/storaged/others-panel.jsx deleted file mode 100644 index 78e91ffa7e80..000000000000 --- a/pkg/storaged/others-panel.jsx +++ /dev/null @@ -1,69 +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 cockpit from "cockpit"; -import React from "react"; - -import { SidePanel } from "./side-panel.jsx"; -import { block_name, fmt_size, make_block_path_cmp, get_other_devices } from "./utils.js"; - -const _ = cockpit.gettext; - -export function other_rows(client, options) { - function make_other(path) { - const block = client.blocks[path]; - const name = block_name(block); - const dev = name.replace(/^\/dev\//, ""); - - return { - client, - kind: false, - testkey: dev, - devname: name, - name, - detail: cockpit.format(_("$0 block device"), fmt_size(block.Size)), - type: _("Block device"), - size: block.Size, - go: () => cockpit.location.go([dev]), - job_path: path, - key: path, - block - }; - } - - return get_other_devices(client) - .sort(make_block_path_cmp(client)) - .map(make_other); -} - -export class OthersPanel extends React.Component { - render() { - const client = this.props.client; - const others = other_rows(client, {}); - - if (others.length > 0) - return ( - - ); - else - 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/overview/overview.jsx b/pkg/storaged/overview/overview.jsx new file mode 100644 index 000000000000..aa3add892b9d --- /dev/null +++ b/pkg/storaged/overview/overview.jsx @@ -0,0 +1,197 @@ +/* + * 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 { DropdownGroup, DropdownList } from '@patternfly/react-core/dist/esm/components/Dropdown/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 "../mdraid/create-dialog.jsx"; +import { create_vgroup } from "../lvm2/create-dialog.jsx"; +import { create_stratis_pool } from "../stratis/create-dialog.jsx"; +import { iscsi_change_name, iscsi_discover } from "../iscsi/create-dialog.jsx"; +import { get_other_devices } from "../utils.js"; + +import { new_page, new_card, StorageCard, ChildrenTable } from "../pages.jsx"; +import { make_drive_page } from "../drive/drive.jsx"; +import { make_lvm2_volume_group_page } from "../lvm2/volume-group.jsx"; +import { make_mdraid_page } from "../mdraid/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/nfs.jsx"; +import { make_iscsi_session_page } from "../iscsi/session.jsx"; +import { make_other_page } from "../block/other.jsx"; + +const _ = cockpit.gettext; + +export function make_overview_page() { + const overview_card = new_card({ + title: _("Storage"), + page_location: [], + page_name: _("Storage"), + component: OverviewCard + }); + + const overview_page = new_page(null, overview_card); + + 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 OverviewCard = ({ card, 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 local_menu_items = [ + menu_item(null, _("Create MDRAID device"), () => create_mdraid()), + menu_item(lvm2_feature, _("Create LVM2 volume group"), () => create_vgroup()), + menu_item(stratis_feature, _("Create Stratis pool"), () => create_stratis_pool()), + ].filter(item => item !== null); + + const net_menu_items = [ + menu_item(nfs_feature, _("New NFS mount"), () => nfs_fstab_dialog(null, null)), + menu_item(iscsi_feature, _("Change iSCSI initiater name"), () => iscsi_change_name()), + menu_item(iscsi_feature, _("Add iSCSI portal"), () => iscsi_discover()), + ].filter(item => item !== null); + + const groups = []; + + if (local_menu_items.length > 0) + groups.push( + + + {local_menu_items} + + ); + + if (net_menu_items.length > 0) + groups.push( + + + {net_menu_items} + + ); + + const actions = ; + + return ( + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/pkg/storaged/pages.jsx b/pkg/storaged/pages.jsx new file mode 100644 index 000000000000..c304c4ef2d23 --- /dev/null +++ b/pkg/storaged/pages.jsx @@ -0,0 +1,809 @@ +/* + * 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, { useState, useRef } from "react"; +import client from "./client"; +import { useEvent } from "hooks.js"; + +import { AlertGroup } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; +import { Card, CardHeader, CardTitle, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { DropdownGroup, DropdownList } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js'; +import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; +import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js"; +import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye/index.js"; +import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; +import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table'; +import { EmptyState, EmptyStateBody } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js"; +import { ExclamationTriangleIcon, ExclamationCircleIcon } from "@patternfly/react-icons"; +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 { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js"; +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; + +import { decode_filename, block_short_name, fmt_size } from "./utils.js"; +import { StorageButton, StorageBarMenu, StorageMenuItem, StorageSize } from "./storage-controls.jsx"; +import { MultipathAlert } from "./multipath.jsx"; +import { JobsPanel } from "./jobs-panel.jsx"; + +const _ = cockpit.gettext; + +/* PAGES and CARDS + + This is the central hub of the Storage application. It implements a + couple of concepts: "page", "card", "crossref", and "action". It + also implements the React components that construct the actual UI + out of them. + + On every change of the client, a tree of pages is constructed. Each + page has a list of cards on it. The cards each have a list of + actions. In addition to that, there are a few crossrefs (cross + references) between pages that don't follow the tree structure. + + The tree of pages starts with "make_overview_page" in + overview/overview.jsx. That function creates the data structures + that represent the overview page (using functions exported from + this file), and is also responsible for kicking of the creation of + its child pages. (And each page construction function of course + also creates the cards and actions for that page). + + Pages can be shown by themselves, or be part of various tables in + other pages. For example, each row in the table in the overview + page represents another page, and clicking on it will navigate to + that page. (In fact, the overview table shows the whole tree. Other + apges show subsets of the tree in their tables.) + + Most of the complications with pages and cards are there to support + putting pages into tables. Without having to do that, we wouldn't + need the pages and cards abstractions at all. + + A page derives its "ID", "Type", "Location", and "Size" properties + from its cards in various ad-hoc and arguably too complex + ways. This is seems necessary since the cards that end up together + on a page can vary significantly depending on the actual storage + configuration of the machine. + + For example, a MDRAID device might have a encrypted LVM2 physical + volume directly on it, or it might have a partition table with a + PV, in which case the card for the PV appears on another page + together with a card for the partition. + + Cards are constructed with a call like this: + + const card = new_card({ + next: next_card, + title: ..., + component: CardComponent, + props: { react_props... } + }); + + A page is constructed with a call like this: + + new_page(parent_page, first_card, { options... }); + + A call to "new_page" will implicitly build the tree by inserting + the new_page into the children of "parent_page". + + The main React component for this application is StoragePage. It + will find the correct page for the browser URL and display it, + together with all the trimmings like the breadcrumb trail. + + The page will be filled with the React components specified by its + cards. Such a component should use StorageCard to get the standard + look of all cards, and to work well with the test helpers. + + Tables of pages are handled by the PageTable component exported + from this file or, more likely, its specialized version + ChildrenTable. + + A ChildrenTable shows all direct and indirect children of a given + page, by using a PageTable appropriately. A raw PageTable can also + show cross references. + + A cross reference associates a page with an arbitrary key. For + example, a page that represents a LVM2 physical volume will + associate itself with the DBusProxy for its volume group. The page + for the volume group will then retrieve all pages associated with + its DBusProxy and put them into a table to show all its physical + volumes. + + When a page appears in such a crossref table, it has dedicated + actions, a dedicated size, and generally looks different from when + it appears in a "normal" childrens table. This is another source of + complication of how cards and pages interact. +*/ + +let pages = null; +let crossrefs = null; + +export function reset_pages() { + pages = new Map(); + crossrefs = new Map(); +} + +function name_from_card(card) { + if (!card) + return null; + return name_from_card(card.next) || card.page_name; +} + +function icon_from_card(card) { + if (!card) + return null; + return icon_from_card(card.next) || card.page_icon; +} + +function key_from_card(card) { + if (!card) + return null; + return key_from_card(card.next) || card.page_key; +} + +function location_from_card(card) { + if (!card) + return null; + return location_from_card(card.next) || card.page_location; +} + +function size_from_card(card) { + if (!card) + return null; + if (card.page_size) + return card.page_size; + return size_from_card(card.next); +} + +export function new_page(parent, card, options) { + const page = { + location: location_from_card(card), + name: name_from_card(card), + icon: icon_from_card(card), + key: key_from_card(card), + parent, + children: [], + card, + options: options || {}, + }; + page.columns = [ + card.title, + card.location, + size_from_card(card), + ]; + if (parent) + parent.children.push(page); + while (card) { + card.page = page; + card = card.next; + } + if (page.location) { + pages.set(JSON.stringify(page.location), page); + if (page.location.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_card({ + title, location, next, + type_extra, id_extra, + page_name, page_icon, page_key, page_location, page_size, + page_block, + for_summary, + has_warning, has_danger, job_path, + component, props, + actions, +}) { + if (page_block) { + page_location = [block_location(page_block)]; + page_name = block_short_name(page_block); + page_size = page_block.Size; + job_path = page_block.path; + } + return { + title, + location, + next, + type_extra, + page_name, + page_icon, + page_key, + page_location, + page_size, + for_summary, + component, + props, + has_warning, + has_danger, + job_path, + actions: actions ? actions.filter(a => !!a) : null, + id_extra, + }; +} + +export function register_crossref(crossref) { + const val = crossrefs.get(crossref.key) || []; + crossref.actions = crossref.actions ? crossref.actions.filter(a => !!a) : null; + 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 NotFoundCard = ({ card }) => { + return {_("Not found")}; +}; + +const not_found_page = new_page(null, new_card({ page_name: _("Not found"), component: NotFoundCard })); + +export function get_page_from_location(location) { + return pages?.get(JSON.stringify(location)) || not_found_page; +} + +/* Common UI things + */ + +export function navigate_away_from_card(card) { + if (!card) + return; + + const loc = cockpit.location; + const page = card.page; + if (page.parent && JSON.stringify(loc.path) == JSON.stringify(page.location)) + loc.go(page.parent.location); +} + +export function navigate_to_new_card_location(card, location) { + if (!card) + return; + + const loc = cockpit.location; + const page = card.page; + if (JSON.stringify(loc.path) == JSON.stringify(page.location)) + loc.go(location); +} + +function make_menu_item(action) { + return action.action(true)} + danger={action.danger} excuse={action.excuse}> + {action.title} + ; +} + +function make_page_kebab(page) { + const items = []; + + function card_item_group(card) { + const a = card.actions || []; + if (a.length > 0) { + let result = {a.map(make_menu_item)}; + if (card.title) { + result = + {result} + ; + } + return result; + } else + return null; + } + + let c = page.card; + while (c) { + const g = card_item_group(c); + if (g) + items.push(g); + c = c.next; + } + + if (items.length == 0) + return null; + + return ; +} + +function make_actions_kebab(actions) { + if (!actions || actions.length == 0) + return null; + + return ; +} + +const ActionButtons = ({ card }) => { + const narrow = useIsNarrow(); + + function for_menu(action) { + // Determine whether a action should get a button or be in the + // menu + + // In a narrow layout, everything goes to the menu + if (narrow) + return true; + + // Everything that is dangerous goes to the menu + if (action.danger) + return true; + + return false; + } + + const buttons = []; + const items = []; + + if (!card.actions) + return null; + + for (const a of card.actions) { + if (for_menu(a)) + items.push(make_menu_item(a)); + else + buttons.push( + a.action(false)} + kind={a.danger ? "danger" : null} excuse={a.excuse}> + {a.title} + ); + } + + if (items.length > 0) + buttons.push(); + + return buttons; +}; + +function page_type_extra(page) { + const extra = []; + let c = page.card; + while (c) { + if (c.type_extra) + extra.push(c.type_extra); + c = c.next; + } + return extra; +} + +function page_type(page) { + const type = page.card.title; + const extra = page_type_extra(page); + if (extra.length > 0) + return type + " (" + extra.join(", ") + ")"; + else + return type; +} + +// PAGE_BLOCK_SUMMARY +// +// Describe a page in a way that is useful to identify it when +// deciding which block device to format, or which block devices to +// make a volume group out of, for example. The block device itself +// (such as /dev/sda5) should not be part of the description; it is in +// another table column already. +// +// The first card on the page that has the "for_summary" flag set +// provides the description, and the type extra of all cards +// leading to it are added. The description is either the title +// of the card, or its id_extra. +// +// For more context, the description for the parent of a page is also +// added. +// +// Thus, we end up with things like "Partition - MDRAID device". + +function page_block_summary_1(page) { + let description = null; + const extra = []; + for (let card = page.card; card; card = card.next) { + if (card.for_summary) { + description = card.id_extra || card.title; + break; + } + if (card.type_extra) + extra.push(card.type_extra); + } + + if (description && extra.length > 0) + description += " (" + extra.join(", ") + ")"; + + return description; +} + +function page_block_summary(page) { + const desc1 = page_block_summary_1(page); + const desc2 = page.parent && page.parent.parent && page_block_summary_1(page.parent); + if (desc1 && desc2) + return desc1 + " - " + desc2; + else + return desc1 || desc2; +} + +let narrow_query = null; + +export const useIsNarrow = (onChange) => { + if (!narrow_query) { + const val = window.getComputedStyle(window.document.body).getPropertyValue("--pf-v5-global--breakpoint--md"); + narrow_query = window.matchMedia(`(max-width: ${val})`); + } + useEvent(narrow_query, "change", onChange); + + return narrow_query.matches; +}; + +export const PageTable = ({ emptyCaption, aria_label, pages, crossrefs, sorted, show_icons }) => { + const [collapsed, setCollapsed] = useState(true); + const firstKeys = useRef(false); + const narrow = useIsNarrow(() => { firstKeys.current = false }); + + let rows = []; + const row_keys = new Set(); + + function make_row(page, crossref, level, border, key) { + function card_has_danger(card) { + if (card) + return card.has_danger || card_has_danger(card.next); + else + return false; + } + + function card_has_warning(card) { + if (card) + return card.has_warning || card_has_warning(card.next); + else + return false; + } + + function card_has_job(card) { + if (card) + return client.path_jobs[card.job_path] || card_has_job(card.next); + else + return false; + } + + let info = null; + if (card_has_job(page.card)) + info = <>{"\n"}; + if (card_has_danger(page.card)) + info = <>{"\n"}{info}; + else if (card_has_warning(page.card)) + info = <>{"\n"}{info}; + + const icon = (show_icons && page.icon) ? : null; + const name = crossref ? page.name : page_display_name(page); + const type = crossref ? page_block_summary(page) : page_type(page); + const location = crossref ? crossref.extra : page.columns[1]; + let size = crossref ? crossref.size : page.columns[2]; + const actions = crossref ? make_actions_kebab(crossref.actions) : make_page_kebab(page); + + if (typeof size === "number") { + if (narrow) + size = fmt_size(size); + else + size = ; + } + + function onClick(event) { + if (!event || event.button !== 0) + return; + + if (page.location) + cockpit.location.go(page.location); + } + + function is_clickable(element) { + return element.classList.contains("pf-m-clickable"); + } + + function next_clickable_sibling(element) { + do { + const next = element.nextElementSibling; + if (next && is_clickable(next)) + return next; + element = next; + } while (element); + + return null; + } + + function previous_clickable_sibling(element) { + do { + const prev = element.previousElementSibling; + if (prev && is_clickable(prev)) + return prev; + element = prev; + } while (element); + + return null; + } + + function onRowKeyDown(event) { + const { code, target } = event; + + if (target.nodeName == "TR") { + if (code == "Space" || code == "Enter") { + if (page.location) + cockpit.location.go(page.location); + event.preventDefault(); + } + if (code == "ArrowDown") { + const next = next_clickable_sibling(target); + if (next) + next.focus(); + event.preventDefault(); + } + if (code == "ArrowUp") { + const prev = previous_clickable_sibling(target); + if (prev) + prev.focus(); + event.preventDefault(); + } + } + } + + const is_new = firstKeys.current != false && !firstKeys.current.has(key); + row_keys.add(key); + + if (narrow) { + rows.push( + + + + { icon && {icon} } + {name}{info} + {actions} + + + {type} + {location} + {size} + + + ); + } else { + const cols = [ + {name}{info}, + {type}, + {location}, + {size}, + {actions ||
}, + ]; + if (show_icons) + cols.unshift({icon}); + + rows.push( + + {cols} + ); + } + } + + function sort(things, accessor, sorted) { + if (sorted === false) + return things; + return things.toSorted((a, b) => accessor(a).localeCompare(accessor(b))); + } + + function make_page_rows(pages, level, last_has_border, key, sorted) { + for (const p of sort(pages, p => p.name, sorted)) { + const is_last = (level == 0 || p == pages[pages.length - 1]); + const p_key = key + ":" + (p.key || p.name); + make_row(p, null, level, is_last && p.children.length == 0 && last_has_border, p_key); + make_page_rows(p.children, level + 1, is_last && last_has_border, p_key, p.options.sorted); + } + } + + function make_crossref_rows(crossrefs) { + for (const c of sort(crossrefs, c => c.card.page.name, sorted)) + make_row(c.card.page, c, 0, true, c.card.page.name); + } + + if (pages) + make_page_rows(pages, 0, true, "", sorted); + else if (crossrefs) + make_crossref_rows(crossrefs); + + if (firstKeys.current === false) + firstKeys.current = row_keys; + else { + firstKeys.current.forEach(v => { + if (!row_keys.has(v)) + firstKeys.current.delete(v); + }); + } + + if (rows.length == 0) { + return + + {emptyCaption} + + ; + } + + let show_all_button = null; + if (rows.length > 50 && collapsed) { + show_all_button = ( + + + ); + rows = rows.slice(0, 50); + } + + return ( +
+ { narrow + ? rows + : + { pages && + + + { show_icons && + + + + + + } + + {rows} + +
} + {_("ID")}{_("Type")}{_("Location")}{_("Size")} +
+ } + {show_all_button} +
); +}; + +export const ChildrenTable = ({ emptyCaption, aria_label, page, show_icons }) => { + return ; +}; + +function page_id_extra(page) { + let extra = ""; + let c = page.card; + while (c) { + if (c.id_extra) + extra += " " + c.id_extra; + c = c.next; + } + return extra; +} + +function page_display_name(page) { + let name = page.name; + const extra = page_id_extra(page); + if (extra) + name = name + " - " + extra; + return name; +} + +const PageCardStackItems = ({ page, plot_state }) => { + const items = []; + let c = page.card; + while (c) { + items.push( + + + + ); + c = c.next; + } + + return items.reverse(); +}; + +export function block_location(block) { + return decode_filename(block.PreferredDevice).replace(/^\/dev\//, ""); +} + +export const StorageCard = ({ card, alert, alerts, actions, children }) => { + return ( + + }}> + {card.title} + + {(alert || (alerts && alerts.length > 0)) && {alert}{alerts}} + {children} + + ); +}; + +export const StorageDescription = ({ title, value, action, children }) => { + if (!value && !action && !children) + return null; + + let content; + if (action && value) { + content = ( + + {value} + {action} + ); + } else { + content = value || action; + } + + return ( + + {title} + + {content}{children} + + ); +}; + +export const StoragePage = ({ location, plot_state }) => { + const page = get_page_from_location(location); + + const parent_crumbs = []; + let pp = page.parent; + while (pp) { + parent_crumbs.unshift( + + {page_display_name(pp)} + + ); + pp = pp.parent; + } + + return ( + + + + { parent_crumbs } + {page_display_name(page)} + + + + + + + + + + ); +}; diff --git a/pkg/storaged/part-tab.jsx b/pkg/storaged/part-tab.jsx deleted file mode 100644 index 9440e2a28127..000000000000 --- a/pkg/storaged/part-tab.jsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2016 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 { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; -import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; -import { StorageButton } from "./storage-controls.jsx"; -import { get_resize_info, free_space_after_part, grow_dialog, shrink_dialog } from "./resize.jsx"; - -import cockpit from "cockpit"; -import * as utils from "./utils.js"; - -const _ = cockpit.gettext; - -export const PartitionTab = ({ client, block, warnings }) => { - const block_part = client.blocks_part[block.path]; - const unused_space_warning = warnings.find(w => w.warning == "unused-space"); - 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 ( -
- - - {_("Name")} - {block_part.Name || "-"} - - { !unused_space && - - {_("Size")} - - {utils.fmt_size(block_part.Size)} -
- {_("Shrink")} - {_("Grow")} -
-
-
- } - - {_("UUID")} - {block_part.UUID} - - - - {_("Type")} - {block_part.Type} - -
- { unused_space && - <> -
- - {cockpit.format(_("Partition size is $0. Content size is $1."), - utils.fmt_size(unused_space_warning.volume_size), - utils.fmt_size(unused_space_warning.content_size))} -
- {_("Shrink partition")} - {_("Grow content")} -
-
- - } -
- ); -}; diff --git a/pkg/storaged/partitions/actions.jsx b/pkg/storaged/partitions/actions.jsx new file mode 100644 index 000000000000..dd936c9c0785 --- /dev/null +++ b/pkg/storaged/partitions/actions.jsx @@ -0,0 +1,40 @@ +/* + * 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 { format_disk } from "./format-disk-dialog.jsx"; + +const _ = cockpit.gettext; + +export function partitionable_block_actions(block, tag) { + const excuse = block.ReadOnly ? _("Device is read-only") : null; + + return [ + (block.Size > 0 + ? { + title: _("Create partition table"), + action: () => format_disk(block), + danger: true, + excuse, + tag + } + : null) + ]; +} diff --git a/pkg/storaged/partitions/format-disk-dialog.jsx b/pkg/storaged/partitions/format-disk-dialog.jsx new file mode 100644 index 000000000000..f1dede36d3bb --- /dev/null +++ b/pkg/storaged/partitions/format-disk-dialog.jsx @@ -0,0 +1,90 @@ +/* + * 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 { + dialog_open, + SelectOne, CheckBoxes, + BlockingMessage, TeardownMessage, + init_active_usage_processes +} from "../dialog.jsx"; +import { get_active_usage, block_name, teardown_active_usage, reload_systemd } from "../utils.js"; +import { job_progress_wrapper } from "../jobs-panel.jsx"; + +const _ = cockpit.gettext; + +export function format_disk(block) { + const usage = get_active_usage(client, block.path, _("initialize"), _("delete")); + + if (usage.Blocking) { + dialog_open({ + Title: cockpit.format(_("$0 is in use"), block_name(block)), + Body: BlockingMessage(usage), + }); + return; + } + + dialog_open({ + Title: cockpit.format(_("Initialize disk $0"), block_name(block)), + Teardown: TeardownMessage(usage), + Fields: [ + SelectOne("type", _("Partitioning"), + { + value: "gpt", + choices: [ + { value: "dos", title: _("Compatible with all systems and devices (MBR)") }, + { + value: "gpt", + title: _("Compatible with modern system and hard disks > 2TB (GPT)") + }, + { value: "empty", title: _("No partitioning") } + ] + }), + CheckBoxes("erase", _("Overwrite"), + { + fields: [ + { tag: "on", title: _("Overwrite existing data with zeros (slower)") } + ], + }), + ], + Action: { + Title: _("Initialize"), + Danger: _("Initializing erases all data on a disk."), + wrapper: job_progress_wrapper(client, block.path), + disable_on_error: usage.Teardown, + action: function (vals) { + const options = { + 'tear-down': { t: 'b', v: true } + }; + if (vals.erase.on) + options.erase = { t: 's', v: "zero" }; + return teardown_active_usage(client, usage) + .then(function () { + return block.Format(vals.type, options); + }) + .then(reload_systemd); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); +} diff --git a/pkg/storaged/partitions/partition-table.jsx b/pkg/storaged/partitions/partition-table.jsx new file mode 100644 index 000000000000..e2873b660fd0 --- /dev/null +++ b/pkg/storaged/partitions/partition-table.jsx @@ -0,0 +1,113 @@ +/* + * 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 { get_partitions } from "../utils.js"; +import { StorageCard, ChildrenTable, new_page, new_card } from "../pages.jsx"; +import { format_dialog } from "../block/format-dialog.jsx"; +import { make_block_page } from "../block/create-pages.jsx"; + +import { make_partition_card, delete_partition } from "./partition.jsx"; + +const _ = cockpit.gettext; + +function make_partition_pages(parent, block) { + const block_ptable = client.blocks_ptable[block.path]; + let counter = 0; + + function make_free_space_page(parent, start, size, enable_dos_extended) { + counter++; + const card = new_card({ + page_name: _("Free space"), + page_key: "free-space-" + counter, + page_size: size, + actions: [ + { + title: _("Create partition"), + action: () => format_dialog(client, block.path, start, size, + enable_dos_extended), + } + ], + }); + new_page(parent, card); + } + + function make_extended_partition_page(parent, partition) { + const card = new_card({ + page_name: _("Extended partition"), + page_size: partition.size, + actions: [ + { title: _("Delete"), action: () => delete_partition(partition.block, card), danger: true }, + ] + }); + const page = new_page(parent, card); + 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 card = make_partition_card(null, p.block); + make_block_page(parent, p.block, card); + } + } + } + + process_partitions(parent, get_partitions(client, block), + block_ptable.Type == 'dos'); +} + +export function make_partition_table_page(parent, block, next_card) { + const block_ptable = client.blocks_ptable[block.path]; + + const parts_card = new_card({ + title: (block_ptable.Type + ? cockpit.format(_("$0 partitions"), block_ptable.Type.toLocaleUpperCase()) + : _("Partitions")), + next: next_card, + component: PartitionsCard, + props: { }, + }); + + const p = new_page(parent, parts_card, { sorted: false }); + make_partition_pages(p, block); +} + +const PartitionsCard = ({ card }) => { + return ( + + + + + + ); +}; diff --git a/pkg/storaged/partitions/partition.jsx b/pkg/storaged/partitions/partition.jsx new file mode 100644 index 000000000000..5f2be9174bb4 --- /dev/null +++ b/pkg/storaged/partitions/partition.jsx @@ -0,0 +1,149 @@ +/* + * 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 { StorageButton } from "../storage-controls.jsx"; +import { dialog_open, init_active_usage_processes, BlockingMessage, TeardownMessage } from "../dialog.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 "../block/resize.jsx"; +import { StorageCard, StorageDescription, new_card, navigate_away_from_card } from "../pages.jsx"; + +const _ = cockpit.gettext; + +export function delete_partition(block, card) { + 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: async function () { + await teardown_active_usage(client, usage); + await block_part.Delete({ 'tear-down': { t: 'b', v: true } }); + await reload_systemd(); + navigate_away_from_card(card); + } + }, + Inits: [ + init_active_usage_processes(client, usage) + ] + }); +} + +export function make_partition_card(next, block) { + const block_part = client.blocks_part[block.path]; + const unused_space_warning = check_unused_space(block.path); + const unused_space = !!unused_space_warning; + let { info, shrink_excuse, grow_excuse } = get_resize_info(client, block, unused_space); + + if (!unused_space_warning && !grow_excuse && free_space_after_part(client, block_part) == 0) { + grow_excuse = _("No free space after this partition"); + } + + const card = new_card({ + title: _("Partition"), + next, + page_block: block, + for_summary: true, + has_warning: !!unused_space_warning, + component: PartitionCard, + props: { block, unused_space_warning, resize_info: info }, + actions: [ + (!unused_space && + { + title: _("Shrink"), + action: () => shrink_dialog(client, block_part, info), + excuse: shrink_excuse, + }), + (!unused_space && + { + title: _("Grow"), + action: () => grow_dialog(client, block_part, info), + excuse: grow_excuse, + }), + { + title: _("Delete"), + action: () => delete_partition(block, card), + danger: true, + }, + ], + }); + return card; +} + +const PartitionCard = ({ card, block, unused_space_warning, resize_info }) => { + const block_part = client.blocks_part[block.path]; + const unused_space = !!unused_space_warning; + + function shrink_to_fit() { + return shrink_dialog(client, block_part, resize_info, true); + } + + function grow_to_fit() { + return grow_dialog(client, block_part, resize_info, true); + } + + return ( + + {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")} + +
+ }> + + + + + + { !unused_space && + + } + + +
); +}; diff --git a/pkg/storaged/pvol-tabs.jsx b/pkg/storaged/pvol-tabs.jsx deleted file mode 100644 index 7cdbe7805c6c..000000000000 --- a/pkg/storaged/pvol-tabs.jsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2016 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 { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; -import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; - -import cockpit from "cockpit"; -import * as utils from "./utils.js"; - -const _ = cockpit.gettext; - -export class PVolTab extends React.Component { - render() { - const block_pvol = this.props.client.blocks_pvol[this.props.block.path]; - const vgroup = block_pvol && this.props.client.vgroups[block_pvol.VolumeGroup]; - - return ( - - - {_("Volume group")} - {vgroup - ? - : "-" - } - - - - {_("Free")} - {block_pvol ? utils.fmt_size(block_pvol.FreeSize) : "-"} - - - ); - } -} - -export class MDRaidMemberTab extends React.Component { - render() { - const mdraid = this.props.client.mdraids[this.props.block.MDRaidMember]; - - return ( - - - {_("RAID device")} - {mdraid - ? - : "-" - } - - - - ); - } -} - -export class VDOBackingTab extends React.Component { - render() { - const vdo = this.props.client.legacy_vdo_overlay.find_by_backing_block(this.props.block); - - return ( - - - {_("VDO device")} - {vdo - ? - : "-" - } - - - - ); - } -} - -export const StratisBlockdevTab = ({ client, block }) => { - const stratis_blockdev = client.blocks_stratis_blockdev[block.path]; - const pool = stratis_blockdev && client.stratis_pools[stratis_blockdev.Pool]; - const stratis_stopped_pool_uuid = client.blocks_stratis_stopped_pool[block.path]; - - const name = pool ? pool.Name : stratis_stopped_pool_uuid; - - return ( - - - {_("Stratis pool")} - {name - ? - : "-" - } - - - - ); -}; diff --git a/pkg/storaged/side-panel.jsx b/pkg/storaged/side-panel.jsx deleted file mode 100644 index 1bdb1684fc81..000000000000 --- a/pkg/storaged/side-panel.jsx +++ /dev/null @@ -1,177 +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 cockpit from "cockpit"; -import React from "react"; - -import { OptionalPanel } from "./optional-panel.jsx"; -import { get_block_link_parts, block_name } from "./utils.js"; - -import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; -import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js"; -import { EmptyState, EmptyStateBody, EmptyStateVariant } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js"; -import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; -import { warnings_icon } from "./warnings.jsx"; - -const _ = cockpit.gettext; - -export class SidePanel extends React.Component { - constructor() { - super(); - this.state = { collapsed: true }; - this.current_rows_keys = []; - this.new_keys = []; - } - - render() { - let show_all_button = null; - let rows = this.props.rows.filter(row => !!row); - - // Find new items for animations - const current_keys = rows.map(row => row.key); - - if (JSON.stringify(this.current_rows_keys) !== JSON.stringify(current_keys)) { - if (this.current_rows_keys.length !== 0) { - const new_keys = current_keys.filter(key => this.current_rows_keys.indexOf(key) === -1); - if (new_keys.length) - this.new_keys.push(...new_keys); - } - this.current_rows_keys = current_keys; - } - - // Collapse items by default if more than 20 - if (this.state.collapsed && rows.length > 20) { - show_all_button = ( - - - ); - rows = rows.slice(0, 20); - } - - rows.forEach(row => { - if (row.key && this.new_keys.indexOf(row.key) !== -1) - row.className = (row.className || "") + " ct-new-item"; - }); - - const children = rows.map(row => row.block ? : ); - - return ( - - { this.props.rows.length > 0 - ? - { children } - { show_all_button } - - : - - {this.props.empty_text} - - - } - - ); - } -} - -class SidePanelRow extends React.Component { - render() { - const { client, job_path } = this.props; - - const go = (event) => { - if (!event) - return; - - // only consider primary mouse button for clicks - if (event.type === 'click' && event.button !== 0) - return; - - // only consider enter button for keyboard events - if (event.type === 'KeyDown' && event.key !== "Enter") - return; - - return this.props.go(); - }; - - const eat_event = (event) => { - // Stop events from disabled actions. Otherwise they would - // reach the element and cause spurious navigation. - event.stopPropagation(); - }; - - let decoration = null; - if (this.props.actions) - decoration = ( -
- {this.props.actions} -
); - else if (client.path_jobs[job_path]) - decoration = ; - else if (client.path_warnings[job_path]) - decoration = warnings_icon(client.path_warnings[job_path]); - - return ( - - - {this.props.name} - {decoration} - - - {this.props.detail} - {this.props.devname} - - - ); - } -} - -class SidePanelBlockRow extends React.Component { - render() { - const { client, block, detail, actions } = this.props; - - const parts = get_block_link_parts(client, block.path); - const name = cockpit.format(parts.format, parts.link); - const backing = client.blocks[block.CryptoBackingDevice]; - - return { cockpit.location.go(parts.location) }} - actions={actions} - className={this.props.className} - />; - } -} diff --git a/pkg/storaged/storage-controls.jsx b/pkg/storaged/storage-controls.jsx index ce3770f70638..2bfcda1a29d1 100644 --- a/pkg/storaged/storage-controls.jsx +++ b/pkg/storaged/storage-controls.jsx @@ -20,17 +20,17 @@ import React, { useState } from 'react'; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; -import { Dropdown, DropdownItem, DropdownToggle, KebabToggle } from '@patternfly/react-core/dist/esm/deprecated/components/Dropdown/index.js'; +import { Dropdown, DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js'; +import { MenuToggle } from '@patternfly/react-core/dist/esm/components/MenuToggle/index.js'; import { Tooltip, TooltipPosition } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js"; import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js"; -import { BarsIcon } from '@patternfly/react-icons'; +import { BarsIcon, EllipsisVIcon } from '@patternfly/react-icons'; import cockpit from "cockpit"; import * as utils from "./utils.js"; import client from "./client.js"; import { dialog_open } from "./dialog.jsx"; -import { fmt_to_fragments } from "utils.jsx"; const _ = cockpit.gettext; @@ -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(); }; } @@ -132,30 +141,6 @@ export const StorageLink = ({ id, excuse, onClick, children }) => ( )} /> ); -/* StorageBlockNavLink - describe a given block device concisely and - allow navigating to its details. - - Properties: - - - client - - block - */ - -export const StorageBlockNavLink = ({ client, block }) => { - if (!block) - return null; - - const parts = utils.get_block_link_parts(client, block.path); - - const link = ( - - ); - - return {fmt_to_fragments(parts.format, link)}; -}; - // StorageOnOff - OnOff switch for asynchronous actions. // @@ -202,34 +187,50 @@ export class StorageOnOff extends React.Component { * in a dangerous color. */ -export const StorageUsageBar = ({ stats, critical, block, offset, total, small }) => { +export const StorageUsageBar = ({ stats, critical, block, offset, total, short }) => { if (!stats) return null; const fraction = stats[0] / stats[1]; const off_fraction = offset / stats[1]; const total_fraction = total / stats[1]; - const labelText = small ? cockpit.format_bytes(stats[0]) : utils.format_fsys_usage(stats[0], stats[1]); + const labelText = utils.format_fsys_usage(stats[0], stats[1]); return ( -
critical ? " pf-m-danger" : "") + (small ? " pf-m-sm" : "")}> - -
+ + {labelText} + +
critical ? " usage-bar-danger" : "") + (short ? " usage-bar-short" : "")} + role="progressbar" aria-valuemin="0" aria-valuemax={stats[1]} aria-valuenow={stats[0]} aria-label={cockpit.format(_("Usage of $0"), block)} aria-valuetext={labelText}> -