diff --git a/pkg/storaged/block-details.jsx b/pkg/storaged/block-details.jsx index f9deed2b6ddc..699f6953b916 100644 --- a/pkg/storaged/block-details.jsx +++ b/pkg/storaged/block-details.jsx @@ -20,24 +20,107 @@ import cockpit from "cockpit"; import React from "react"; -import { Card, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js"; +import { Card, CardHeader, 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 { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; +import { fmt_to_fragments } from "utils.jsx"; import * as utils from "./utils.js"; import { StdDetailsLayout } from "./details.jsx"; -import * as Content from "./content-views.jsx"; +import { block_description, create_tabs } from "./content-views.jsx"; +import { StorageButton } from "./storage-controls.jsx"; const _ = cockpit.gettext; +export function block_nav_parents(client, block) { + // XXX - terrible. The client should build a proper hierachical model. + + const drive = client.drives[block.Drive]; + const drive_block = drive && client.drives_block[drive.path]; + if (drive && drive_block) { + return [ + { + location: ["drive", utils.block_name(drive_block).replace(/^\/dev\//, "")], + title: utils.drive_name(drive) + } + ]; + } + + const mdraid = client.mdraids[block.MDRaid]; + if (mdraid) { + return [{ location: ["md", mdraid.UUID], title: "XXX - mdraid" }]; + } + + const lvol = client.blocks_lvm2[block.path] && client.lvols[client.blocks_lvm2[block.path].LogicalVolume]; + const pool = lvol && client.lvols[lvol.Pool]; + const vgroup = lvol && client.vgroups[lvol.VolumeGroup]; + + if (lvol && vgroup && pool) { + return [{ location: ["vg", vgroup.Name, pool.Name], title: pool.Name }, + { location: ["vg", vgroup.Name], title: vgroup.Name } + ]; + } + + if (lvol && vgroup) { + return [{ location: ["vg", vgroup.Name], title: vgroup.Name }]; + } + + const stratis_fsys = client.blocks_stratis_fsys[block.path]; + const stratis_pool = stratis_fsys && client.stratis_pools[stratis_fsys.Pool]; + if (stratis_fsys && stratis_pool) { + return [{ location: ["pool", stratis_pool.Uuid], title: stratis_pool.Name }]; + } + + return []; +} + export class BlockDetails extends React.Component { render() { + const client = this.props.client; const block = this.props.block; + const tabs = create_tabs(this.props.client, block, {}); + + const actions = tabs.actions; + tabs.menu_actions.forEach(a => { + if (!a.only_narrow) + actions.push({a.title}); + }); + tabs.menu_danger_actions.forEach(a => { + if (!a.only_narrow) + actions.push({a.title}); + }); + + const cparts = utils.get_block_link_parts(client, block.path); + + function is_container(r) { + return r.name == _("Logical volume") || r.name == _("Partition"); + } + + const container_renderers = tabs.renderers.filter(is_container); + const content_renderers = tabs.renderers.filter(r => !is_container(r)); const header = ( - {_("Block")} + + + {block_description(client, block, {}).type} + + + + {_("Stored on")} + + {fmt_to_fragments( + cparts.format, + )} + + {_("storage", "Capacity")} { utils.fmt_size_long(block.Size) } @@ -47,11 +130,24 @@ export class BlockDetails extends React.Component { { utils.block_name(block) } + { content_renderers.map(t =>
) }
); - const content = ; + const content = container_renderers.map(t => { + return ( + + + + {t.name} + + + + + + ); + }); return ; } diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js index 2ce4cbdcc7ac..665892dcb5b7 100644 --- a/pkg/storaged/client.js +++ b/pkg/storaged/client.js @@ -331,6 +331,93 @@ function update_indices() { client.lvols_pool_members[path].sort(function (a, b) { return a.Name.localeCompare(b.Name) }); } + function summarize_stripe(lv_size, segments) { + const pvs = { }; + let total_size = 0; + for (const [, size, pv] of segments) { + if (!pvs[pv]) + pvs[pv] = 0; + pvs[pv] += size; + total_size += size; + } + if (total_size < lv_size) + pvs["/"] = lv_size - total_size; + return pvs; + } + + client.lvols_stripe_summary = { }; + client.lvols_status = { }; + for (path in client.lvols) { + const struct = client.lvols[path].Structure; + const lvol = client.lvols[path]; + + let summary; + let status = ""; + if (lvol.Layout != "thin" && struct && struct.segments) { + summary = summarize_stripe(struct.size.v, struct.segments.v); + if (summary["/"]) + status = "partial"; + } else if (struct && struct.data && struct.metadata && + (struct.data.v.length == struct.metadata.v.length || struct.metadata.v.length == 0)) { + summary = []; + const n_total = struct.data.v.length; + let n_missing = 0; + for (let i = 0; i < n_total; i++) { + const data_lv = struct.data.v[i]; + const metadata_lv = struct.metadata.v[i] || { size: { v: 0 }, segments: { v: [] } }; + + if (!data_lv.segments || (metadata_lv && !metadata_lv.segments)) { + summary = undefined; + break; + } + + const s = summarize_stripe(data_lv.size.v + metadata_lv.size.v, + data_lv.segments.v.concat(metadata_lv.segments.v)); + if (s["/"]) + n_missing += 1; + + summary.push(s); + } + if (n_missing > 0) { + status = "partial"; + if (lvol.Layout == "raid1") { + if (n_total - n_missing >= 1) + status = "degraded"; + } + if (lvol.Layout == "raid10") { + // This is correct for two-way mirroring, which is + // the only setup supported by lvm2. + if (n_missing > n_total / 2) { + // More than half of the PVs are gone -> at + // least one mirror has definitely lost both + // halves. + status = "partial"; + } else if (n_missing > 1) { + // Two or more PVs are lost -> one mirror + // might have lost both halves + status = "degraded-maybe-partial"; + } else { + // Only one PV is missing -> no mirror has + // lost both halves. + status = "degraded"; + } + } + if (lvol.Layout == "raid4" || lvol.Layout == "raid5") { + if (n_missing <= 1) + status = "degraded"; + } + if (lvol.Layout == "raid6") { + if (n_missing <= 2) + status = "degraded"; + } + } + } + if (summary) { + client.lvols_stripe_summary[path] = summary; + client.lvols_status[path] = status; + } + } + client.stratis_poolnames_pool = { }; for (path in client.stratis_pools) { pool = client.stratis_pools[path]; @@ -457,6 +544,26 @@ 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++) { + console.log("??", block.Symlinks[i], session.data.target_name); + if (utils.decode_filename(block.Symlinks[i]).includes(session.data.target_name)) { + client.drives_iscsi_session[path] = session; + if (!client.iscsi_sessions_drives[session_path]) + client.iscsi_sessions_drives[session_path] = []; + client.iscsi_sessions_drives[session_path].push(client.drives[path]); + } + } + } + } + client.path_jobs = { }; function enter_job(job) { if (!job.Objects || !job.Objects.length) diff --git a/pkg/storaged/content-views.jsx b/pkg/storaged/content-views.jsx index 4a41564766c0..161b046291d6 100644 --- a/pkg/storaged/content-views.jsx +++ b/pkg/storaged/content-views.jsx @@ -20,7 +20,7 @@ import cockpit from "cockpit"; import { dialog_open, TextInput, PassInput, SelectOne, SizeSlider, CheckBoxes, - BlockingMessage, TeardownMessage, Message, + SelectSpaces, BlockingMessage, TeardownMessage, Message, init_active_usage_processes } from "./dialog.jsx"; import * as utils from "./utils.js"; @@ -32,7 +32,6 @@ import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/inde import { DropdownSeparator } from '@patternfly/react-core/dist/esm/deprecated/components/Dropdown/index.js'; -import { ExclamationTriangleIcon } from "@patternfly/react-icons"; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; import { ListingTable } from "cockpit-components-table.jsx"; @@ -46,9 +45,12 @@ import { FilesystemTab, is_mounted, mounting_dialog, get_fstab_config } from "./ 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 { PVolTab, MDRaidMemberTab, VDOBackingTab, StratisBlockdevTab } from "./pvol-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"; +import { vgroup_rename, vgroup_delete } from "./vgroup-details.jsx"; const _ = cockpit.gettext; @@ -74,7 +76,16 @@ function next_default_logical_volume_name(client, vgroup, prefix) { return name; } -function create_tabs(client, target, options) { +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 }; + }); +} + +export function create_tabs(client, target, options) { function endsWith(str, suffix) { return str.indexOf(suffix, str.length - suffix.length) !== -1; } @@ -85,6 +96,7 @@ function create_tabs(client, target, options) { const block_fsys = content_block && client.blocks_fsys[content_block.path]; const block_lvm2 = block && client.blocks_lvm2[block.path]; + const block_pvol = content_block && client.blocks_pvol[content_block.path]; const block_swap = content_block && client.blocks_swap[content_block.path]; const block_stratis_blockdev = block && client.blocks_stratis_blockdev[block.path]; @@ -99,6 +111,8 @@ function create_tabs(client, target, options) { (block_stratis_blockdev && client.stratis_pools[block_stratis_blockdev.Pool]) || block_stratis_stopped_pool); + const target_name = lvol ? utils.lvol_name(lvol) : block ? utils.block_name(block) : null; + // Adjust for encryption leaking out of Stratis if (is_crypto && is_stratis) is_crypto = false; @@ -106,19 +120,40 @@ function create_tabs(client, target, options) { 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_hints = []; const tab_menu_actions = []; const tab_menu_danger_actions = []; - function add_action(title, func) { - tab_actions.push({title}); - tab_menu_actions.push({ title, func, only_narrow: true }); + function add_action(title, func, unified_hint) { + if (options.unified) { + tab_menu_actions.push({ title, func }); + if (unified_hint) + tab_hints.push(unified_hint); + } else { + 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) { - tab_actions.push({title}); - tab_menu_danger_actions.push({ title, func, only_narrow: true }); + if (options.unified) { + tab_menu_danger_actions.push({ title, func }); + } else { + 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) { @@ -132,11 +167,16 @@ function create_tabs(client, target, options) { const tabs = []; function add_tab(name, renderer, for_content, associated_warnings) { + // No tabs on the unified overview + // XXX - what about warnings? + if (options.unified) + return; + let tab_warnings = []; if (associated_warnings) tab_warnings = warnings.filter(w => associated_warnings.indexOf(w.warning) >= 0); if (tab_warnings.length > 0) - name =
{name}
; + name =
{warnings_icon(tab_warnings)} {name}
; tabs.push( { name, @@ -146,6 +186,7 @@ function create_tabs(client, target, options) { block: for_content ? content_block : block, lvol, warnings: tab_warnings, + options } }); } @@ -156,7 +197,7 @@ function create_tabs(client, target, options) { return; dialog_open({ - Title: _("Create thin volume"), + Title: cockpit.format(_("Create thin volume in $0/$1"), vgroup.Name, lvol.Name), Fields: [ TextInput("name", _("Name"), { @@ -185,14 +226,14 @@ function create_tabs(client, target, options) { add_tab(_("Pool"), PoolVolTab); add_action(_("Create thin volume"), create_thin); } else { - add_tab(_("Volume"), BlockVolTab, false, ["unused-space"]); + add_tab(_("Logical volume"), BlockVolTab, false, ["unused-space", "partial-lvol"]); if (client.vdo_vols[lvol.path]) add_tab(_("VDO pool"), VDOPoolTab); } } - if (options.is_partition) { + if (block && client.blocks_part[block.path]) { add_tab(_("Partition"), PartitionTab, false, ["unused-space"]); } @@ -200,6 +241,16 @@ function create_tabs(client, target, options) { if (is_filesystem) { add_tab(_("Filesystem"), FilesystemTab, true, ["mismounted-fsys"]); + } else if ((content_block && content_block.IdUsage == "raid" && content_block.IdType == "LVM2_member") || + (block_pvol && client.vgroups[block_pvol.VolumeGroup])) { + add_tab(_("LVM2 physical volume"), PVolTab, true); + } else if (is_stratis) { + add_tab(_("Stratis pool"), StratisBlockdevTab, false); + } else if ((content_block && content_block.IdUsage == "raid") || + (content_block && client.mdraids[content_block.MDRaidMember])) { + add_tab(_("RAID member"), MDRaidMemberTab, true); + } else if (content_block && client.legacy_vdo_overlay.find_by_backing_block(content_block)) { + add_tab(_("VDO backing"), VDOBackingTab, true); } else if (content_block && (content_block.IdUsage == "raid" || client.legacy_vdo_overlay.find_by_backing_block(content_block))) { // no tab for these @@ -243,7 +294,7 @@ function create_tabs(client, target, options) { return; dialog_open({ - Title: _("Unlock"), + Title: _("Unlock $0", target_name), Fields: [ PassInput("passphrase", _("Passphrase"), {}) ], @@ -264,9 +315,9 @@ function create_tabs(client, target, options) { } 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")); + add_action(_("Mount"), () => mounting_dialog(client, block, "mount"), _("not mounted")); else - add_action(_("Unlock"), unlock); + add_action(_("Unlock"), unlock, _("locked")); } } @@ -279,8 +330,12 @@ function create_tabs(client, target, options) { } function create_snapshot() { + const vgroup = lvol && client.vgroups[lvol.VolumeGroup]; + if (!vgroup) + return; + dialog_open({ - Title: _("Create snapshot"), + Title: cockpit.format(_("Create snapshot of $0/$1"), vgroup.Name, lvol.Name), Fields: [ TextInput("name", _("Name"), { validate: utils.validate_lvm2_name }), @@ -294,12 +349,68 @@ function create_tabs(client, target, options) { }); } + 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); + add_action(_("Activate"), activate, _("not active")); } } if (client.lvols[lvol.ThinPool]) { @@ -331,29 +442,27 @@ function create_tabs(client, target, options) { if (block) block_part = client.blocks_part[block.path]; - let name, danger; + let 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) { + if (target_name) { const usage = utils.get_active_usage(client, target.path, _("delete")); if (usage.Blocking) { dialog_open({ - Title: cockpit.format(_("$0 is in use"), name), + Title: cockpit.format(_("$0 is in use"), target_name), Body: BlockingMessage(usage) }); return; } dialog_open({ - Title: cockpit.format(_("Permanently delete $0?"), name), + Title: cockpit.format(_("Permanently delete $0?"), target_name), Teardown: TeardownMessage(usage), Action: { Danger: danger, @@ -391,19 +500,20 @@ function create_tabs(client, target, options) { 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")); + add_action(_("Mount"), () => mounting_dialog(client, content_block, "mount"), _("not mounted")); } return { renderers: tabs, actions: tab_actions, + hints: tab_hints, menu_actions: tab_menu_actions, menu_danger_actions: tab_menu_danger_actions, - has_warnings: warnings.length > 0 + warnings }; } -function block_description(client, block, options) { +export 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]; @@ -423,7 +533,7 @@ function block_description(client, block, options) { type = C_("storage-id-desc", "Filesystem (encrypted)"); used_for = mount_point; } else if (block_stratis_stopped_pool) { - type = _("Stratis member"); + type = _("Stratis block device"); used_for = block_stratis_stopped_pool; link = ["pool", used_for]; omit_encrypted_label = true; @@ -438,7 +548,7 @@ function block_description(client, block, options) { } else if (block.IdUsage == "raid") { if (block_pvol && client.vgroups[block_pvol.VolumeGroup]) { const vgroup = client.vgroups[block_pvol.VolumeGroup]; - type = _("LVM2 member"); + type = _("LVM2 physical volume"); used_for = vgroup.Name; link = ["vg", used_for]; size = [block_pvol.Size - block_pvol.FreeSize, block_pvol.Size]; @@ -450,14 +560,14 @@ function block_description(client, block, options) { 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"); + type = _("Stratis block device"); used_for = pool.Name; link = ["pool", pool.Uuid]; omit_encrypted_label = true; } else if (block.IdType == "LVM2_member") { - type = _("LVM2 member"); + type = _("LVM2 physical volume"); } else if (block.IdType == "stratis") { - type = _("Stratis member"); + type = _("Stratis block device"); omit_encrypted_label = true; } else { type = _("RAID member"); @@ -481,6 +591,9 @@ function block_description(client, block, options) { if (cleartext && !omit_encrypted_label) type = cockpit.format(_("$0 (encrypted)"), type); + if (options.unified) + link = null; + return { type, used_for, @@ -521,11 +634,20 @@ function append_row(client, rows, level, key, name, desc, tabs, job_object, opti let info = null; if (job_object && client.path_jobs[job_object]) info = ; - if (tabs.has_warnings) - info = <>{info}; + if (tabs.warnings.length > 0) + info = <>{info}{warnings_icon(tabs.warnings)}; if (info) info = <>{"\n"}{info}; + let location = desc.used_for; + if (tabs.hints.length > 0) { + const hints = "(" + tabs.hints.join(", ") + ")"; + if (location) + location += " " + hints; + else + location = hints; + } + const cols = [ { title: ( @@ -535,7 +657,7 @@ function append_row(client, rows, level, key, name, desc, tabs, job_object, opti ) }, { title: desc.type }, - { title: desc.link ? : desc.used_for }, + { title: desc.link ? : location }, { title: desc.size.length ? @@ -548,7 +670,8 @@ function append_row(client, rows, level, key, name, desc, tabs, job_object, opti rows.push({ props: { key, className: "content-level-" + level }, columns: cols, - expandedContent: tabs.renderers.length > 0 ? : null + expandedContent: tabs.renderers.length > 0 ? : null, + go: options.go }); } @@ -556,7 +679,8 @@ 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); + append_row(client, rows, level, block.path, utils.block_name(block), desc, tabs, block.path, + { ...options, go: () => cockpit.location.go([utils.block_name(block).replace(/^\/dev\//, "")]) }); } function append_partitions(client, rows, level, block, options) { @@ -570,20 +694,29 @@ function append_partitions(client, rows, level, block, options) { format_dialog(client, block.path, start, size, is_dos_partitioned && level <= device_level); } - const btn = ( - - {_("Create partition")} - - ); - - const item = ( - - {_("Create partition")} - ); + let btn, item, menu; - const menu = ; + if (options.unified) { + btn = null; + item = ( + + {_("Create partition")} + ); + menu = ; + } else { + btn = ( + + {_("Create partition")} + ); + item = ( + + {_("Create partition")} + ); + menu = ; + } const cols = [ _("Free space"), @@ -595,7 +728,11 @@ function append_partitions(client, rows, level, block, options) { rows.push({ columns: cols, - props: { key: "free-space-" + rows.length.toString(), className: "content-level-" + level } + props: { + key: "free-space-" + rows.length.toString(), + className: "content-level-" + level, + }, + go: options.go, }); } @@ -635,7 +772,8 @@ function append_device(client, rows, level, block, options) { export function block_content_rows(client, block, options) { const rows = []; - append_device(client, rows, 0, block, options); + append_device(client, rows, options.level || 0, block, + { go: () => utils.go_to_block(client, block.path), ...options }); return rows; } @@ -696,6 +834,20 @@ function format_disk(client, block) { }); } +export function block_menu_items(client, block, options) { + function onClick() { + if (block.ReadOnly) + return Promise.reject(_("Device is read-only")); + format_disk(client, block); + } + + return [ + + {_("Create partition table")} + + ]; +} + const BlockContent = ({ client, block, allow_partitions }) => { if (!block) return null; @@ -718,14 +870,66 @@ const BlockContent = ({ client, block, allow_partitions }) => { else title = _("Content"); + function onRowClick(event, row) { + if (!event || event.button !== 0) + return; + + // StorageBarMenu sets this to tell us not to navigate when + // the kebabs are opened. + if (event.defaultPrevented) + return; + + if (row.go) + row.go(); + } + return ( {title} - + + + ); +}; + +export const ThinPoolContent = ({ client, pool }) => { + const create_volume = ( + + {_("Create thin volume")} + + ); + + function onRowClick(event, row) { + if (!event || event.button !== 0) + return; + + // StorageBarMenu sets this to tell us not to navigate when + // the kebabs are opened. + if (event.defaultPrevented) + return; + + if (row.go) + row.go(); + } + + return ( + + + {_("Thin volumes in pool")} + + + @@ -751,11 +955,20 @@ function append_logical_volume_block(client, rows, level, block, lvol, options) } : 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); + const vgroup = client.vgroups[lvol.VolumeGroup]; + append_row(client, rows, level, block.path, lvol.Name, desc, tabs, block.path, + { ...options, go: () => cockpit.location.go(["vg", vgroup.Name, lvol.Name]) }); +} + +function append_thin_pool_volumes(client, rows, level, pool, options) { + client.lvols_pool_members[pool.path].forEach(function (member_lvol) { + append_logical_volume(client, rows, level + 1, member_lvol, options); + }); } function append_logical_volume(client, rows, level, lvol, options) { let tabs, desc, block; + const vgroup = client.vgroups[lvol.VolumeGroup]; if (lvol.Type == "pool") { desc = { @@ -763,15 +976,14 @@ function append_logical_volume(client, rows, level, lvol, options) { 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); - }); + append_row(client, rows, level, lvol.Name, lvol.Name, desc, tabs, false, + { ...options, go: () => cockpit.location.go(["vg", vgroup.Name, lvol.Name]) }); + append_thin_pool_volumes(client, rows, level, lvol, options); } else { block = client.lvols_block[lvol.path]; - if (block) + if (block) { append_logical_volume_block(client, rows, level, block, lvol, options); - else { + } else { // If we can't find the block for a active // volume, Storaged or something below is // probably misbehaving, and we show it as @@ -782,11 +994,18 @@ function append_logical_volume(client, rows, level, lvol, options) { 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); + append_row(client, rows, level, lvol.Name, lvol.Name, desc, tabs, false, + { ...options, go: () => cockpit.location.go(["vg", vgroup.Name, lvol.Name]) }); } } } +export function thin_pool_content_rows(client, pool, options) { + const rows = []; + append_thin_pool_volumes(client, rows, options.level || 0, pool, options); + return rows; +} + export function vgroup_content_rows(client, vgroup, options) { const rows = []; @@ -795,7 +1014,7 @@ export function vgroup_content_rows(client, vgroup, options) { (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); + append_logical_volume(client, rows, options.level || 0, lvol, options); }); return rows; } @@ -819,6 +1038,8 @@ function create_logical_volume(client, vgroup) { if (vgroup.FreeSize == 0) return; + const can_do_layouts = !!vgroup.CreatePlainVolumeWithLayout; + const purposes = [ { value: "block", @@ -830,14 +1051,105 @@ function create_logical_volume(client, vgroup) { */ ]; + const layouts = [ + { + value: "linear", + title: _("Linear (at least one physical volume)"), + min_pvs: 1, + }, + { + value: "raid0", + title: _("Striped (RAID 0, at least two physical volumes)"), + min_pvs: 2, + }, + { + value: "raid1", + title: _("Mirrored (RAID 1, at least two physical volumes)"), + min_pvs: 2, + }, + { + value: "raid10", + title: _("Striped and mirrored (RAID 10, at least four physical volumes, even number)"), + min_pvs: 4, + }, + { + value: "raid5", + title: _("Distributed parity (RAID 5, at least three physical volumes)"), + min_pvs: 3, + }, + { + value: "raid6", + title: _("Double distributed parity (RAID 6, at least five physical volumes)"), + 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)") }); + const pvs_as_spaces = pvs_to_spaces(client, client.vgroups_pvols[vgroup.path].filter(pvol => pvol.FreeSize > 0)); + + /* 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."), + }; + + 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"), + Title: cockpit.format(_("Create logical volume in $0"), vgroup.Name), Fields: [ TextInput("name", _("Name"), { @@ -853,42 +1165,31 @@ function create_logical_volume(client, vgroup) { { visible: vals => vals.purpose === 'vdo' && need_vdo_install, }), - - /* Not Implemented - { SelectOne: "layout", - Title: _("Layout"), - Options: [ - { value: "linear", Title: _("Linear"), - selected: true - }, - { value: "striped", Title: _("Striped (RAID 0)"), - enabled: raid_is_possible - }, - { value: "raid1", Title: _("Mirrored (RAID 1)"), - enabled: raid_is_possible - }, - { value: "raid10", Title: _("Striped and mirrored (RAID 10)"), - enabled: raid_is_possible - }, - { value: "raid4", Title: _("With dedicated parity (RAID 4)"), - enabled: raid_is_possible - }, - { value: "raid5", Title: _("With distributed parity (RAID 5)"), - enabled: raid_is_possible - }, - { value: "raid6", Title: _("With double distributed parity (RAID 6)"), - enabled: raid_is_possible - } - ], - }, - */ + 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) + }), + SelectOne("layout", _("Layout"), + { + value: "linear", + choices: layouts, + 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"), { @@ -928,12 +1229,43 @@ function create_logical_volume(client, vgroup) { } }), ], + update: (dlg, vals, trigger) => { + if (vals.purpose == 'block' && (trigger == "layout" || trigger == "pvs" || trigger == "purpose")) { + for (const lay of layouts) { + lay.disabled = (vals.pvs.length < lay.min_pvs); + 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: layouts, + 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") - return vgroup.CreatePlainVolume(vals.name, vals.size, { }); - else if (vals.purpose == "pool") + 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()) @@ -954,6 +1286,26 @@ function create_logical_volume(client, vgroup) { }); } +export function vgroup_menu_items(client, vgroup, options) { + function onClick() { + if (vgroup.FreeSize == 0) + return Promise.reject(_("No free space")); + create_logical_volume(client, vgroup); + } + + return [ + + {_("Create logical volume")} + , + vgroup_rename(client, vgroup)}> + {_("Rename volume group")} + , + vgroup_delete(client, vgroup)}> + {_("Delete volume group")} + , + ]; +} + export class VGroup extends React.Component { constructor () { super(); @@ -972,7 +1324,11 @@ export class VGroup extends React.Component { const vgroup = this.props.vgroup; const client = this.props.client; - const excuse = vgroup.FreeSize == 0 && _("No free space"); + 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)} @@ -981,6 +1337,19 @@ export class VGroup extends React.Component { ); + function onRowClick(event, row) { + if (!event || event.button !== 0) + return; + + // StorageBarMenu sets this to tell us not to navigate when + // the kebabs are opened. + if (event.defaultPrevented) + return; + + if (row.go) + row.go(); + } + return ( @@ -988,10 +1357,12 @@ export class VGroup extends React.Component { + rows={vgroup_content_rows(client, vgroup, { unified: true })} /> ); diff --git a/pkg/storaged/details.jsx b/pkg/storaged/details.jsx index a9723443afd9..3e838be3aadb 100644 --- a/pkg/storaged/details.jsx +++ b/pkg/storaged/details.jsx @@ -23,13 +23,12 @@ 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 { BlockDetails, block_nav_parents } from "./block-details.jsx"; import { DriveDetails } from "./drive-details.jsx"; -import { VGroupDetails } from "./vgroup-details.jsx"; +import { VGroupDetails, LVolDetails } from "./vgroup-details.jsx"; import { MDRaidDetails } from "./mdraid-details.jsx"; import { VDODetails } from "./vdo-details.jsx"; import { NFSDetails } from "./nfs-details.jsx"; @@ -46,37 +45,21 @@ export const StdDetailsLayout = ({ client, alerts, header, content, sidebar }) = ; - if (sidebar) { - return ( - <> - { top } - - -
- { content } - -
-
- - { sidebar } - -
- - ); - } else { - return ( - <> - { top } - -
- { content } -
- -
- - ); - } + return ( + <> + { top } + { sidebar + ? { sidebar } + : null + } + +
+ { content } +
+ +
+ + ); }; export class Details extends React.Component { @@ -85,22 +68,34 @@ export class Details extends React.Component { let body = null; let name = this.props.name; - if (this.props.type == "block") { + let crumbs = []; + if (this.props.type == "drive") { 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) { + // XXX- crumbs for drives in iscsi sessions + } + } else if (this.props.type == "block") { + const block = client.slashdevs_block["/dev/" + this.props.name]; + if (block) { name = utils.block_name(block); body = ; + crumbs = block_nav_parents(client, block); } } else if (this.props.type == "vg") { const vgroup = client.vgnames_vgroup[this.props.name]; if (vgroup) { - name = vgroup.Name; - body = ; + const lvol = client.vgroups_lvols[vgroup.path].find(lv => lv.Name == this.props.name2); + if (lvol) { + name = lvol.Name; + body = ; + crumbs = [{ title: vgroup.Name, location: ["vg", vgroup.Name] }]; + } else { + name = vgroup.Name; + body = ; + } } } else if (this.props.type == "mdraid") { const mdraid = client.uuids_mdraid[this.props.name]; @@ -137,6 +132,12 @@ export class Details extends React.Component { {_("Storage")} + { crumbs.map(c => ( + + {c.title} + )) + } {name} diff --git a/pkg/storaged/dialog.jsx b/pkg/storaged/dialog.jsx index 1a9fb46dad98..d861fc7ed324 100644 --- a/pkg/storaged/dialog.jsx +++ b/pkg/storaged/dialog.jsx @@ -507,6 +507,14 @@ export const dialog_open = (def) => { update(); }, + get_options: (tag) => { + for (const f of fields) { + if (f.tag == tag) { + return f.options; + } + } + }, + set_options: (tag, new_options) => { fields.forEach(f => { if (f.tag == tag) { @@ -722,8 +730,9 @@ export const SelectSpaces = (tag, title, options) => { tag, title, options, - initial_value: [], + initial_value: options.value || [], hasNoPaddingTop: options.spaces.length == 0, + render: (val, change) => { if (options.spaces.length === 0) return {options.empty_warning}; @@ -737,8 +746,9 @@ export const SelectSpaces = (tag, title, options) => { const desc = block === spc.desc ? "" : spc.desc; const on_change = (_event, checked) => { + // Be careful to keep "val" in the same order as "options.spaces". if (checked && !selected) - change(val.concat(spc)); + change(options.spaces.filter(v => val.indexOf(v) >= 0 || v == spc)); else if (!checked && selected) change(val.filter(v => (v != spc))); }; @@ -747,6 +757,8 @@ export const SelectSpaces = (tag, title, options) => {