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,
+ cockpit.location.go(cparts.location)}>
+ {cparts.link}
+ )}
+
+
{_("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 ? cockpit.location.go(desc.link)}>{desc.used_for} : desc.used_for },
+ { title: desc.link ? cockpit.location.go(desc.link)}>{desc.used_for} : 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 }
-
-
-
-
-
- >
- );
- } 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) => {
diff --git a/pkg/storaged/drives-panel.jsx b/pkg/storaged/drives-panel.jsx
index d60a887a739b..4fa0d39f7eb5 100644
--- a/pkg/storaged/drives-panel.jsx
+++ b/pkg/storaged/drives-panel.jsx
@@ -26,7 +26,7 @@ import { fmt_size, drive_name, decode_filename, block_name } from "./utils.js";
const _ = cockpit.gettext;
const C_ = cockpit.gettext;
-export function drive_rows(client) {
+export function drive_rows(client, options) {
function cmp_drive(path_a, path_b) {
return client.drives[path_a].SortKey.localeCompare(client.drives[path_b].SortKey);
}
@@ -87,7 +87,7 @@ export function drive_rows(client) {
size: drive.Size,
type,
detail: desc,
- go: () => cockpit.location.go([dev]),
+ go: () => cockpit.location.go(["drive", dev]),
block: drive && client.drives_block[path],
job_path: path,
key: path
diff --git a/pkg/storaged/fsys-tab.jsx b/pkg/storaged/fsys-tab.jsx
index a6465500b488..2b4c5b8a2a46 100644
--- a/pkg/storaged/fsys-tab.jsx
+++ b/pkg/storaged/fsys-tab.jsx
@@ -717,7 +717,7 @@ export class FilesystemTab extends React.Component {
{ !stratis_fsys &&
- {_("Name")}
+ {_("Filesystem label")}
{this.props.block.IdLabel || "-"}
diff --git a/pkg/storaged/iscsi-panel.jsx b/pkg/storaged/iscsi-panel.jsx
index 8a97649a20bb..930baf8ac7fc 100644
--- a/pkg/storaged/iscsi-panel.jsx
+++ b/pkg/storaged/iscsi-panel.jsx
@@ -25,7 +25,7 @@ import { CheckIcon, EditIcon, PlusIcon, TrashIcon } from "@patternfly/react-icon
import { SidePanel } from "./side-panel.jsx";
import { } from "./utils.js";
-import { StorageButton } from "./storage-controls.jsx";
+import { StorageButton, StorageMenuItem } from "./storage-controls.jsx";
import { dialog_open, TextInput, PassInput, SelectRow } from "./dialog.jsx";
const _ = cockpit.gettext;
@@ -186,6 +186,22 @@ function iscsi_change_name(client) {
});
}
+export function iscsi_menu_items(client, options) {
+ if (!client.features.iscsi)
+ return [];
+
+ return [
+ iscsi_change_name(client)}>
+ {_("Change iSCSI initiator name")}
+ ,
+ iscsi_discover(client)}>
+ {_("Add iSCSI portal")}
+ ,
+ ];
+}
+
export function iscsi_rows(client, options) {
function cmp_session(path_a, path_b) {
const a = client.iscsi_sessions[path_a];
@@ -218,8 +234,11 @@ export function iscsi_rows(client, options) {
actions,
kind: "array",
name: session.data.target_name || "",
+ type: _("iSCSI portal"),
key: path,
- detail: session.data.persistent_address + ":" + session.data.persistent_port
+ detail: session.data.persistent_address + ":" + session.data.persistent_port,
+ location: session.data.persistent_address + ":" + session.data.persistent_port,
+ portal: session
};
}
@@ -227,6 +246,18 @@ export function iscsi_rows(client, options) {
.map(make_session);
}
+export function portal_menu_items(client, session, options) {
+ function iscsi_remove() {
+ return session.Logout({ 'node.startup': { t: 's', v: "manual" } });
+ }
+
+ return [
+
+ {_("Disconnect")}
+
+ ];
+}
+
export class IscsiPanel extends React.Component {
constructor() {
super();
diff --git a/pkg/storaged/lvol-tabs.jsx b/pkg/storaged/lvol-tabs.jsx
index 719e6c45b674..d9ee6bd31a1f 100644
--- a/pkg/storaged/lvol-tabs.jsx
+++ b/pkg/storaged/lvol-tabs.jsx
@@ -24,12 +24,23 @@ 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, decode_filename } 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"),
@@ -46,6 +57,117 @@ function lvol_rename(lvol) {
});
}
+export const StructureDescription = ({ client, lvol }) => {
+ const struct = lvol.Structure;
+
+ if (!struct)
+ 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 pvs_box(used, block_path) {
+ if (block_path != "/") {
+ const block = client.blocks[block_path];
+ return
+
+ {block ? decode_filename(block.PreferredDevice).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 ? decode_filename(block.PreferredDevice).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;
@@ -81,11 +203,22 @@ export class BlockVolTab extends React.Component {
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")}
+ {_("Logical volume name")}
{this.props.lvol.Name}
@@ -93,6 +226,17 @@ export class BlockVolTab extends React.Component {
+ { (layout && layout != "linear") &&
+
+ {_("Layout")}
+
+
+ {layout_desc[layout] || layout}
+
+
+
+ }
+
{ !unused_space &&
{_("Size")}
@@ -146,7 +290,7 @@ export class PoolVolTab extends React.Component {
return (
- {_("Name")}
+ {_("Logical volume name")}
{this.props.lvol.Name}
@@ -207,7 +351,7 @@ export const VDOPoolTab = ({ client, lvol }) => {
return (
- {_("Name")}
+ {_("VDO name")}
{vdo_pool_vol.Name}
diff --git a/pkg/storaged/mdraid-details.jsx b/pkg/storaged/mdraid-details.jsx
index 9d11a27dd0f3..502ae7272687 100644
--- a/pkg/storaged/mdraid-details.jsx
+++ b/pkg/storaged/mdraid-details.jsx
@@ -27,7 +27,7 @@ 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 { StorageButton, StorageMenuItem } from "./storage-controls.jsx";
import {
dialog_open, SelectSpaces, BlockingMessage, TeardownMessage,
init_active_usage_processes
@@ -249,6 +249,26 @@ function mdraid_delete(client, mdraid) {
});
}
+export function mdraid_menu_items(client, mdraid, options) {
+ /* 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;
+
+ return [
+ (running
+ ? mdraid_stop(client, mdraid)}>
+ {_("Stop RAID device")}
+
+ : mdraid_start(client, mdraid)}>
+ {_("Start RAID device")}
+ ),
+ mdraid_delete(client, mdraid)}>
+ {_("Delete RAID device")}
+ ,
+ ];
+}
+
export class MDRaidDetails extends React.Component {
render() {
const client = this.props.client;
diff --git a/pkg/storaged/mdraids-panel.jsx b/pkg/storaged/mdraids-panel.jsx
index 5b7108a76a63..59695b454568 100644
--- a/pkg/storaged/mdraids-panel.jsx
+++ b/pkg/storaged/mdraids-panel.jsx
@@ -37,9 +37,13 @@ function mdraid_row(client, path) {
name: mdraid_name(mdraid),
devname: block && block_name(block),
detail: fmt_size(mdraid.Size) + " " + _("RAID device"),
+ type: _("RAID device"),
+ size: mdraid.Size,
job_path: path,
key: path,
- go: () => cockpit.location.go(["mdraid", mdraid.UUID])
+ go: () => cockpit.location.go(["mdraid", mdraid.UUID]),
+ block,
+ mdraid
};
}
diff --git a/pkg/storaged/nfs-details.jsx b/pkg/storaged/nfs-details.jsx
index 15d082a6e801..31e3cca61e4b 100644
--- a/pkg/storaged/nfs-details.jsx
+++ b/pkg/storaged/nfs-details.jsx
@@ -28,7 +28,7 @@ import {
} from "./dialog.jsx";
import { StdDetailsLayout } from "./details.jsx";
-import { StorageButton, StorageUsageBar } from "./storage-controls.jsx";
+import { StorageButton, StorageUsageBar, StorageMenuItem } from "./storage-controls.jsx";
import { parse_options, unparse_options, extract_option } from "./utils.js";
const _ = cockpit.gettext;
@@ -273,6 +273,16 @@ function remove(client, entry) {
});
}
+export function nfs_content_menu_items(client, entry) {
+ return [
+ entry.mounted
+ ? unmount(client, entry)}>{_("Unmount")}
+ : mount(client, entry)}>{_("Mount")} ,
+ edit(client, entry)}>{_("Edit")} ,
+ remove(client, entry)}>{_("Remove")} ,
+ ];
+}
+
export class NFSDetails extends React.Component {
render() {
const client = this.props.client;
diff --git a/pkg/storaged/nfs-panel.jsx b/pkg/storaged/nfs-panel.jsx
index ccce2e691a5e..49236123a693 100644
--- a/pkg/storaged/nfs-panel.jsx
+++ b/pkg/storaged/nfs-panel.jsx
@@ -23,24 +23,53 @@ 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 { StorageButton, StorageUsageBar, StorageBarMenu } from "./storage-controls.jsx";
+import { nfs_fstab_dialog, nfs_content_menu_items } 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;
+export function nfs_feature(client) {
+ return {
+ is_enabled: () => client.features.nfs,
+ package: client.get_config("nfs_client_package", false),
+ enable: () => {
+ client.features.nfs = true;
+ client.nfs.start();
+ }
+ };
+}
- function make_nfs_mount(entry) {
- let fsys_size;
- if (entry.mounted)
- fsys_size = client.nfs.get_fsys_size(entry);
+export function nfs_rows(client, options) {
+ 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];
+ const server = entry.fields[0].split(":")[0];
+ const remote_dir = entry.fields[0].split(":")[1];
+ if (options.unified)
+ return {
+ props: { entry, key: entry.fields[1] },
+ columns: [
+ { title: server + " " + remote_dir },
+ { title: _("NFS mount") },
+ { title: entry.fields[1] },
+ {
+ title: entry.mounted
+ ?
+ : "",
+ props: { className: "pf-v5-u-text-align-right" }
+ },
+ {
+ title: ,
+ props: { className: "pf-v5-c-table__action content-action" }
+ }
+ ],
+ go: () => cockpit.location.go(["nfs", entry.fields[0], entry.fields[1]])
+ };
+ else
return {
props: { entry, key: entry.fields[1] },
columns: [
@@ -53,9 +82,15 @@ export class NFSPanel extends React.Component {
}
]
};
- }
+ }
- const mounts = client.nfs.entries.map(make_nfs_mount);
+ return client.nfs.entries.map(make_nfs_mount);
+}
+
+export class NFSPanel extends React.Component {
+ render() {
+ const client = this.props.client;
+ const mounts = nfs_rows(client, {});
function add() {
nfs_fstab_dialog(client, null);
@@ -67,15 +102,6 @@ export class NFSPanel extends React.Component {
);
- 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;
@@ -87,7 +113,7 @@ export class NFSPanel extends React.Component {
client={client}
title={_("NFS mounts")}
actions={actions}
- feature={nfs_feature}
+ feature={nfs_feature(client)}
not_installed_text={_("NFS support not installed")}
install_title={_("Install NFS support")}>
.
+ */
+
+import cockpit from "cockpit";
+import React from "react";
+
+import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
+import { Card, CardHeader, CardTitle, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
+import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
+import { ListingTable } from "cockpit-components-table.jsx";
+
+import { StorageBarMenu } from "./storage-controls.jsx";
+
+import { thing_menu_items, thing_rows } from "./things-panel.jsx";
+import { drive_rows } from "./drives-panel.jsx";
+import { nfs_rows } from "./nfs-panel.jsx";
+import { iscsi_menu_items, iscsi_rows, portal_menu_items } from "./iscsi-panel.jsx";
+import { other_rows } from "./others-panel.jsx";
+import { block_content_rows, vgroup_content_rows, block_menu_items, vgroup_menu_items } from "./content-views.jsx";
+import { stratis_content_rows, pool_menu_items } from "./stratis-details.jsx";
+import { mdraid_menu_items } from "./mdraid-details.jsx";
+
+import { StoragePlots } from "./plot.jsx";
+import { JobsPanel } from "./jobs-panel.jsx";
+import { StorageLogsPanel } from "./logs-panel.jsx";
+import { fmt_size } from "./utils.js";
+
+const _ = cockpit.gettext;
+
+// XXX - this is terrible code, and is just meant to bring us to a
+// point where we can play with the new UX without having to disturb
+// the existing code too much.
+
+export const OverviewX = ({ client, plot_state }) => {
+ const menu_items = [].concat(
+ thing_menu_items(client, { unified: true }),
+ iscsi_menu_items(client, { unified: true }));
+
+ const actions = ;
+
+ const d_rows = drive_rows(client, { unified: true });
+ const i_rows = iscsi_rows(client, { unified: true });
+
+ // Move iSCSI drives from d_rows to their place in i_rows. Ugh.
+ for (let i = 0; i < i_rows.length; i++) {
+ const session = i_rows[i].portal;
+ for (let j = 0; j < d_rows.length; j++) {
+ if (client.drives_iscsi_session[d_rows[j].block.Drive] == session) {
+ d_rows[j].level = 1;
+ i_rows.splice(i + 1, 0, d_rows[j]);
+ d_rows.splice(j, 1);
+ i += 1;
+ j -= 1;
+ }
+ }
+ }
+
+ const top_rows = [].concat(
+ d_rows,
+ thing_rows(client, { unified: true }),
+ i_rows,
+ other_rows(client, { unified: true }));
+
+ let rows = [];
+ top_rows.forEach(t => {
+ let m = [];
+ if (t.block)
+ m = m.concat(block_menu_items(client, t.block, { unified: true }));
+ if (t.vgroup)
+ m = m.concat(vgroup_menu_items(client, t.vgroup, { unified: true }));
+ if (t.pool)
+ m = m.concat(pool_menu_items(client, t.pool, { unified: true }));
+ if (t.portal)
+ m = m.concat(portal_menu_items(client, t.portal, { unified: true }));
+ if (t.mdraid)
+ m = m.concat(mdraid_menu_items(client, t.mdraid, { unified: true }));
+ const actions = (m.length > 0
+ ?
+ : null);
+ const level = t.level || 0;
+ rows.push({
+ props: {
+ key: t.path,
+ className: "content-level-" + level,
+ },
+ columns: [
+ { title: t.name }, // XXX - use "ID", name is taken.
+ { title: t.type },
+ { title: t.location || t.devname },
+ { title: fmt_size(t.size), props: { className: "pf-v5-u-text-align-right" } },
+ { title: actions, props: { className: "pf-v5-c-table__action content-action" } },
+ ],
+ go: t.go,
+ });
+ if (t.block)
+ rows = rows.concat(block_content_rows(client, t.block, { unified: true, level: level + 1 }));
+ if (t.vgroup)
+ rows = rows.concat(vgroup_content_rows(client, t.vgroup, { unified: true, level: level + 1 }));
+ if (t.pool)
+ rows = rows.concat(stratis_content_rows(client, t.pool, { unified: true, level: level + 1 }));
+ });
+
+ rows = rows.concat(nfs_rows(client, { unified: true }));
+
+ 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 (
+
+
+
+
+
+
+
+
+
+
+ {_("Storage")}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/pkg/storaged/part-tab.jsx b/pkg/storaged/part-tab.jsx
index 9440e2a28127..5dc9388ec408 100644
--- a/pkg/storaged/part-tab.jsx
+++ b/pkg/storaged/part-tab.jsx
@@ -51,7 +51,7 @@ export const PartitionTab = ({ client, block, warnings }) => {
- {_("Name")}
+ {_("Partition label")}
{block_part.Name || "-"}
{ !unused_space &&
@@ -67,12 +67,12 @@ export const PartitionTab = ({ client, block, warnings }) => {
}
- {_("UUID")}
+ {_("Partition UUID")}
{block_part.UUID}
- {_("Type")}
+ {_("Partition type")}
{block_part.Type}
diff --git a/pkg/storaged/resize.jsx b/pkg/storaged/resize.jsx
index 734a97b1789d..49f17f199bad 100644
--- a/pkg/storaged/resize.jsx
+++ b/pkg/storaged/resize.jsx
@@ -17,6 +17,7 @@
* along with Cockpit; If not, see
.
*/
+import React from "react";
import cockpit from "cockpit";
import {
block_name, get_active_usage, teardown_active_usage,
@@ -27,14 +28,15 @@ import {
request_passphrase_on_error_handler
} from "./crypto-keyslots.jsx";
import {
- dialog_open, SizeSlider, BlockingMessage, TeardownMessage,
+ 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";
const _ = cockpit.gettext;
-function lvol_or_part_and_fsys_resize(client, lvol_or_part, size, offline, passphrase) {
+function lvol_or_part_and_fsys_resize(client, lvol_or_part, size, offline, passphrase, pvs) {
let fsys;
let crypto_overhead;
let vdo;
@@ -133,7 +135,7 @@ function lvol_or_part_and_fsys_resize(client, lvol_or_part, size, offline, passp
if (size != orig_size) {
// Both LogicalVolume and Partition have a Resize method
// with the same signature, so this will work on both.
- return lvol_or_part.Resize(size, { });
+ return lvol_or_part.Resize(size, { pvs: pvs ? { t: 'ao', v: pvs } : undefined });
} else
return Promise.resolve();
}
@@ -233,19 +235,74 @@ export function free_space_after_part(client, part) {
export function grow_dialog(client, lvol_or_part, info, to_fit) {
let title, block, name, orig_size, max_size, allow_infinite, round_size;
+ let has_subvols, subvols, pvs_as_spaces, initial_pvs;
+
+ function compute_max_size(spaces) {
+ const layout = lvol_or_part.Layout;
+ const pvs = spaces.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));
+
+ if (!has_subvols) {
+ return sum;
+ } else if (layout == "raid0") {
+ return n_pvs * min;
+ } else if (layout == "raid1") {
+ return min;
+ } else if (layout == "raid10") {
+ return (n_pvs / 2) * min;
+ } else if ((layout == "raid4" || layout == "raid5")) {
+ return (n_pvs - 1) * min;
+ } else if (layout == "raid6") {
+ return (n_pvs - 2) * min;
+ } else
+ return 0; // not-covered: internal error
+ }
if (lvol_or_part.iface == "org.freedesktop.UDisks2.LogicalVolume") {
const vgroup = client.vgroups[lvol_or_part.VolumeGroup];
const pool = client.lvols[lvol_or_part.ThinPool];
+ pvs_as_spaces = pvs_to_spaces(client, client.vgroups_pvols[vgroup.path].filter(pvol => pvol.FreeSize > 0));
+ subvols = client.lvols_stripe_summary[lvol_or_part.path];
+ has_subvols = subvols && (lvol_or_part.Layout == "mirror" || lvol_or_part.Layout.indexOf("raid") == 0);
+
+ if (!has_subvols)
+ initial_pvs = pvs_as_spaces;
+ else {
+ initial_pvs = [];
+
+ // Step 1: Find the spaces that are already used for a
+ // subvolume. If a subvolume uses more than one, prefer the
+ // one with more available space.
+ for (const sv of subvols) {
+ let sel = null;
+ for (const p in sv) {
+ for (const spc of pvs_as_spaces)
+ if (spc.block.path == p && (!sel || sel.size < spc.size))
+ sel = spc;
+ }
+ if (sel)
+ initial_pvs.push(sel);
+ }
+
+ // Step 2: Select missing one randomly.
+ for (const pv of pvs_as_spaces) {
+ if (initial_pvs.indexOf(pv) == -1 && initial_pvs.length < subvols.length)
+ initial_pvs.push(pv);
+ }
+ }
+
title = _("Grow logical volume");
block = client.lvols_block[lvol_or_part.path];
name = lvol_or_part.Name;
orig_size = lvol_or_part.Size;
- max_size = pool ? pool.Size * 3 : lvol_or_part.Size + vgroup.FreeSize;
+ max_size = pool ? pool.Size * 3 : lvol_or_part.Size + compute_max_size(initial_pvs);
allow_infinite = !!pool;
round_size = vgroup.ExtentSize;
} else {
+ has_subvols = false;
title = _("Grow partition");
block = client.blocks[lvol_or_part.path];
name = block_name(block);
@@ -266,9 +323,22 @@ export function grow_dialog(client, lvol_or_part, info, to_fit) {
}
let grow_size;
- let size_fields = [];
+ const size_fields = [];
if (!to_fit) {
- size_fields = [
+ if (has_subvols || lvol_or_part.Layout == "linear")
+ size_fields.push(
+ SelectSpaces("pvs", _("Physical Volumes"),
+ {
+ spaces: pvs_as_spaces,
+ value: initial_pvs,
+ min_selected: subvols.length,
+ validate: val => {
+ if (has_subvols && subvols.length != val.length)
+ return cockpit.format(_("Exactly $0 physical volumes must be selected"),
+ subvols.length);
+ }
+ }));
+ size_fields.push(
SizeSlider("size", _("Size"),
{
value: orig_size,
@@ -276,8 +346,7 @@ export function grow_dialog(client, lvol_or_part, info, to_fit) {
max: max_size,
allow_infinite,
round: round_size,
- })
- ];
+ }));
} else {
grow_size = block.Size;
}
@@ -287,14 +356,60 @@ export function grow_dialog(client, lvol_or_part, info, to_fit) {
if (block && block.IdType == "crypto_LUKS" && block.IdVersion == 2)
passphrase_fields = existing_passphrase_fields(_("Resizing an encrypted filesystem requires unlocking the disk. Please provide a current disk passphrase."));
- if (!usage.Teardown && size_fields.length + passphrase_fields.length === 0) {
+ if (!has_subvols && !usage.Teardown && size_fields.length + passphrase_fields.length === 0) {
return lvol_or_part_and_fsys_resize(client, lvol_or_part, grow_size, info.grow_needs_unmount, null);
}
+ function prepare_pvs(pvs) {
+ if (!pvs)
+ return pvs;
+
+ pvs = pvs.map(spc => spc.block.path);
+
+ if (!has_subvols)
+ return pvs;
+
+ const subvol_pvs = [];
+
+ // Step 1: Find PVs that are already used by a subvolume
+ subvols.forEach((sv, idx) => {
+ subvol_pvs[idx] = null;
+ for (const pv in sv) {
+ if (pvs.indexOf(pv) >= 0 && subvol_pvs.indexOf(pv) == -1) {
+ subvol_pvs[idx] = pv;
+ break;
+ }
+ }
+ });
+
+ // Step 2: Use the rest for the leftover subvolumes
+ subvols.forEach((sv, idx) => {
+ if (!subvol_pvs[idx]) {
+ for (const pv of pvs) {
+ if (subvol_pvs.indexOf(pv) == -1) {
+ subvol_pvs[idx] = pv;
+ break;
+ }
+ }
+ }
+ });
+
+ return subvol_pvs;
+ }
+
const dlg = dialog_open({
Title: title,
Teardown: TeardownMessage(usage),
+ Body: has_subvols &&
{cockpit.format(_("Exactly $0 physical volumes need to be selected, one for each stripe of the logical volume."), subvols.length)}
,
Fields: size_fields.concat(passphrase_fields),
+ update: (dlg, vals, trigger) => {
+ if (vals.pvs) {
+ const max = lvol_or_part.Size + compute_max_size(vals.pvs);
+ if (vals.size > max)
+ dlg.set_values({ size: max });
+ dlg.set_options("size", { max });
+ }
+ },
Action: {
Title: _("Grow"),
action: function (vals) {
@@ -303,7 +418,8 @@ export function grow_dialog(client, lvol_or_part, info, to_fit) {
return (lvol_or_part_and_fsys_resize(client, lvol_or_part,
to_fit ? grow_size : vals.size,
info.grow_needs_unmount,
- vals.passphrase || recovered_passphrase)
+ vals.passphrase || recovered_passphrase,
+ prepare_pvs(vals.pvs))
.catch(request_passphrase_on_error_handler(dlg, vals, recovered_passphrase, block)));
});
}
diff --git a/pkg/storaged/side-panel.jsx b/pkg/storaged/side-panel.jsx
index 93357f26e0c9..8c592e027660 100644
--- a/pkg/storaged/side-panel.jsx
+++ b/pkg/storaged/side-panel.jsx
@@ -20,158 +20,77 @@
import cockpit from "cockpit";
import React from "react";
-import { OptionalPanel } from "./optional-panel.jsx";
+import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
+import { ListingTable } from "cockpit-components-table.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 { ExclamationTriangleIcon } from '@patternfly/react-icons';
-
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);
+ const 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);
+ const children = rows.map(row => {
+ if (row.block) {
+ const client = row.client;
+ const parts = get_block_link_parts(client, row.block.path);
+ const backing = client.blocks[row.block.CryptoBackingDevice];
+ row.name = cockpit.format(parts.format, parts.link);
+ row.devname = block_name(backing || row.block);
+ row.go = () => { cockpit.location.go([row.devname.replace(/^\/dev\//, "")]) };
}
- this.current_rows_keys = current_keys;
- }
-
- // Collapse items by default if more than 20
- if (this.state.collapsed && rows.length > 20) {
- show_all_button = (
-
- ev.key === "Enter" && this.setState({ collapsed: false })}
- onClick={() => { this.setState({ collapsed: false }) }}>
- {this.props.show_all_text || _("Show all")}
-
- );
- 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 eat_event = (event) => {
+ // Stop events from disabled actions. Otherwise they would
+ // reach the
element and cause spurious navigation.
+ event.stopPropagation();
+ };
+
+ return {
+ props: { },
+ columns: [
+ row.name,
+ row.devname,
+ row.detail,
+ {
+ title:
+ {row.actions}
+
+ }
+ ],
+ go: row.go
+ };
});
- 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)
+ function onRowClick(event, row) {
+ if (!event || event.button !== 0)
return;
- // only consider enter button for keyboard events
- if (event.type === 'KeyDown' && event.key !== "Enter")
+ // StorageBarMenu sets this to tell us not to navigate when
+ // the kebabs are opened.
+ if (event.defaultPrevented)
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] || this.props.hasWarning)
- decoration = ;
+ if (row.go)
+ row.go();
+ }
return (
-
-
- {this.props.name}
- {decoration}
-
-
- {this.props.detail}
- {this.props.devname}
-
-
+
+
+ {this.props.title}
+
+
+
+
+
);
}
}
-
-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..5671b294dd87 100644
--- a/pkg/storaged/storage-controls.jsx
+++ b/pkg/storaged/storage-controls.jsx
@@ -240,12 +240,20 @@ export const StorageBarMenu = ({ label, isKebab, onlyNarrow, menuItems }) => {
if (!client.superuser.allowed)
return null;
+ function onToggle(event, isOpen) {
+ // Tell Overview that we handled this event. We can't use
+ // stopPrevention() since the Toggles depend on seeing other
+ // Togglers events at the top level to close themselves.
+ event.preventDefault();
+ setIsOpen(isOpen);
+ }
+
let toggle;
if (isKebab)
- toggle = setIsOpen(isOpen)} />;
+ toggle = ;
else
toggle = setIsOpen(isOpen)} aria-label={label}>
+ onToggle={onToggle} aria-label={label}>
;
diff --git a/pkg/storaged/storage.scss b/pkg/storaged/storage.scss
index ec20302e06ae..ceb4b63427a5 100644
--- a/pkg/storaged/storage.scss
+++ b/pkg/storaged/storage.scss
@@ -197,19 +197,13 @@ tr[class*="content-level-"] {
--multiplier: 0;
--offset: calc(var(--pf-v5-global--spacer--lg) * var(--multiplier));
- // Move the button over
- > .pf-v5-c-table__toggle > button {
+ > td:first-child {
position: relative;
inset-inline-start: var(--offset);
}
-
- // Add space for the button and offset
- > .pf-v5-c-table__toggle + td {
- padding-inline-start: calc(var(--offset) + var(--pf-v5-c-table--cell--PaddingLeft));
- }
}
-@for $i from 1 through 10 {
+@for $i from 0 through 10 {
tr.content-level-#{$i} {
--multiplier: #{$i};
}
@@ -447,3 +441,57 @@ td button.pf-m-link {
display: none;
}
}
+
+.storage-pvs-box {
+ border: 1px solid var(--pf-v5-global--palette--purple-400);
+ background: var(--pf-v5-global--palette--purple-100);
+}
+
+.pf-v5-theme-dark .storage-pvs-box {
+ border: 1px solid var(--pf-v5-global--palette--purple-300);
+ background: var(--pf-v5-global--palette--purple-500);
+}
+
+.storage-pvs-pv-box {
+ padding: 0.3em;
+}
+
+.storage-pvs-pv-box:not(:last-child) {
+ border-inline-end: 1px solid var(--pf-v5-global--palette--purple-400);
+}
+
+.pf-v5-theme-dark .storage-pvs-pv-box:not(:last-child) {
+ border-inline-end: 1px solid var(--pf-v5-global--palette--purple-300);
+}
+
+.storage-pvs-pv-box-dev {
+ font-size: 120%;
+ font-weight: bold;
+}
+
+.storage-stripe-box {
+ border: 1px solid var(--pf-v5-global--palette--purple-400);
+ background: var(--pf-v5-global--palette--purple-100);
+}
+
+.pf-v5-theme-dark .storage-stripe-box {
+ border: 1px solid var(--pf-v5-global--palette--purple-300);
+ background: var(--pf-v5-global--palette--purple-500);
+}
+
+.storage-stripe-pv-box {
+ padding: 0.3em;
+}
+
+.storage-stripe-pv-box:not(:last-child) {
+ border-block-end: 1px solid var(--pf-v5-global--palette--purple-400);
+}
+
+.pf-v5-theme-dark .storage-stripe-pv-box:not(:last-child) {
+ border-block-end: 1px solid var(--pf-v5-global--palette--purple-300);
+}
+
+.storage-stripe-pv-box-dev {
+ font-size: 120%;
+ font-weight: bold;
+}
diff --git a/pkg/storaged/storaged.jsx b/pkg/storaged/storaged.jsx
index 4a04b8e381e9..67f8554cb385 100644
--- a/pkg/storaged/storaged.jsx
+++ b/pkg/storaged/storaged.jsx
@@ -30,7 +30,7 @@ import { PlotState } from "plot.js";
import client from "./client";
import { MultipathAlert } from "./multipath.jsx";
-import { Overview } from "./overview.jsx";
+import { OverviewX } from "./overviewx.jsx";
import { Details } from "./details.jsx";
import { update_plot_state } from "./plot.jsx";
@@ -88,7 +88,7 @@ class StoragePage extends React.Component {
return (
<>
- {detail || }
+ {detail || }
>
);
}
diff --git a/pkg/storaged/stratis-details.jsx b/pkg/storaged/stratis-details.jsx
index 25a3d0289f93..70d00c77697f 100644
--- a/pkg/storaged/stratis-details.jsx
+++ b/pkg/storaged/stratis-details.jsx
@@ -47,7 +47,8 @@ import {
encode_filename, decode_filename,
get_active_usage, teardown_active_usage,
get_available_spaces, prepare_available_spaces,
- reload_systemd, for_each_async
+ reload_systemd, for_each_async,
+ block_name
} from "./utils.js";
import { fmt_to_fragments } from "utils.jsx";
import { mount_explanation } from "./format-dialog.jsx";
@@ -65,8 +66,11 @@ export function check_stratis_warnings(client, enter_warning) {
for (const p in client.stratis_pools) {
const blockdevs = client.stratis_pool_blockdevs[p] || [];
+ const pool = client.stratis_pools[p];
if (blockdevs.some(bd => bd.NewPhysicalSize[0] && Number(bd.NewPhysicalSize[1]) > Number(bd.TotalPhysicalSize)))
enter_warning(p, { warning: "unused-blockdevs" });
+ if (pool.AvailableActions && pool.AvailableActions !== "fully_operational")
+ enter_warning(p, { warning: "not-fully-operational" });
}
}
@@ -444,8 +448,12 @@ export function stratis_content_rows(client, pool, options) {
const menuitems = [];
if (!fs_is_mounted) {
- actions.push({_("Mount")} );
- menuitems.push({_("Mount")} );
+ if (options.unified) {
+ menuitems.push({_("Mount")} );
+ } else {
+ actions.push({_("Mount")} );
+ menuitems.push({_("Mount")} );
+ }
}
if (fs_is_mounted)
@@ -480,10 +488,16 @@ export function stratis_content_rows(client, pool, options) {
}
];
+ if (options.unified) {
+ // insert "Type" column
+ cols.splice(1, 0, { title: _("Stratis filesystem") });
+ }
+
return {
- props: { key: fsys.Name },
+ props: { key: fsys.Name, className: "content-level-" + (options.level || 0) },
columns: cols,
- expandedContent:
+ expandedContent: options.unified ? null : ,
+ go: () => cockpit.location.go([block_name(block).replace(/^\/dev\//, "")])
};
}
@@ -497,7 +511,7 @@ function create_fs(client, pool) {
const managed_fsys_sizes = client.features.stratis_managed_fsys_sizes && !pool.Overprovisioning;
dialog_open({
- Title: _("Create filesystem"),
+ Title: cockpit.format(_("Create filesystem in $0"), pool.Name),
Fields: [
TextInput("name", _("Name"),
{
@@ -624,6 +638,24 @@ function rename_pool(client, pool) {
});
}
+export function pool_menu_items(client, pool, options) {
+ return [
+ create_fs(client, pool)}>
+ {_("Create filesystem")}
+ ,
+ rename_pool(client, pool)}>
+ {_("Rename pool")}
+ ,
+ delete_pool(client, pool)}>
+ {_("Delete pool")}
+ ,
+ ];
+}
+
export const StratisPoolDetails = ({ client, pool }) => {
const key_desc = (pool.Encrypted &&
pool.KeyDescription[0] &&
@@ -883,7 +915,20 @@ export const StratisPoolDetails = ({ client, pool }) => {
);
const sidebar = ;
- const rows = stratis_content_rows(client, pool, {});
+ const rows = stratis_content_rows(client, pool, { unified: true });
+
+ 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();
+ }
const content = (
@@ -899,7 +944,13 @@ export const StratisPoolDetails = ({ client, pool }) => {
!!row)} />
diff --git a/pkg/storaged/stratis-panel.jsx b/pkg/storaged/stratis-panel.jsx
index 8808d173c353..78cd6e1e5a33 100644
--- a/pkg/storaged/stratis-panel.jsx
+++ b/pkg/storaged/stratis-panel.jsx
@@ -56,12 +56,14 @@ function stratis_pool_row(client, path) {
return {
client,
name: pool.Name,
- hasWarning: pool.AvailableActions && pool.AvailableActions !== "fully_operational",
key: path,
devname: "/dev/stratis/" + pool.Name + "/",
detail: cockpit.format(_("$0 Stratis pool"), fmt_size(pool.TotalPhysicalSize)),
+ size: pool.TotalPhysicalSize,
+ type: _("Stratis pool"),
go: () => cockpit.location.go(["pool", pool.Uuid]),
- job_path: path
+ job_path: path,
+ pool,
};
}
diff --git a/pkg/storaged/things-panel.jsx b/pkg/storaged/things-panel.jsx
index 043f7528fc45..941166ffa744 100644
--- a/pkg/storaged/things-panel.jsx
+++ b/pkg/storaged/things-panel.jsx
@@ -27,6 +27,8 @@ import { create_vgroup, vgroup_rows } from "./vgroups-panel.jsx";
import { vdo_rows } from "./vdos-panel.jsx";
import { StorageBarMenu, StorageMenuItem } from "./storage-controls.jsx";
import { stratis_feature, create_stratis_pool, stratis_rows } from "./stratis-panel.jsx";
+import { nfs_feature } from "./nfs-panel.jsx";
+import { nfs_fstab_dialog } from "./nfs-details.jsx";
import { dialog_open } from "./dialog.jsx";
const _ = cockpit.gettext;
@@ -73,7 +75,8 @@ export function thing_menu_items(client, options) {
const menu_items = [
menu_item(null, _("Create RAID device"), () => create_mdraid(client)),
menu_item(lvm2_feature, _("Create LVM2 volume group"), () => create_vgroup(client)),
- menu_item(stratis_feature(client), _("Create Stratis pool"), () => create_stratis_pool(client))
+ menu_item(stratis_feature(client), _("Create Stratis pool"), () => create_stratis_pool(client)),
+ options.unified ? menu_item(nfs_feature(client), _("New NFS mount"), () => nfs_fstab_dialog(client, null)) : null,
].filter(item => item !== null);
return menu_items;
diff --git a/pkg/storaged/utils.js b/pkg/storaged/utils.js
index 20971698b330..a66702d27264 100644
--- a/pkg/storaged/utils.js
+++ b/pkg/storaged/utils.js
@@ -339,11 +339,14 @@ export function get_block_link_parts(client, path) {
location = ["vdo", vdo.name];
link = cockpit.format(_("VDO device $0"), vdo.name);
} else {
- location = [block_name(block).replace(/^\/dev\//, "")];
- if (client.drives[block.Drive])
- link = drive_name(client.drives[block.Drive]);
- else
+ if (client.drives[block.Drive]) {
+ const drive = client.drives[block.Drive];
+ location = ["drive", block_name(client.drives_block[drive.path]).replace(/^\/dev\//, "")];
+ link = drive_name(drive);
+ } else {
+ location = [block_name(block).replace(/^\/dev\//, "")];
link = block_name(block);
+ }
}
}
diff --git a/pkg/storaged/vdos-panel.jsx b/pkg/storaged/vdos-panel.jsx
index d2cbff10fe4d..6ccef65ff563 100644
--- a/pkg/storaged/vdos-panel.jsx
+++ b/pkg/storaged/vdos-panel.jsx
@@ -34,6 +34,8 @@ function vdo_row(client, vdo) {
name: vdo.name,
devname: vdo.dev,
detail: fmt_size(vdo.logical_size) + " " + _("VDO device"),
+ size: vdo.logical_size,
+ type: _("VDO device"),
go: () => cockpit.location.go(["vdo", vdo.name]),
job_path: block && block.path
};
diff --git a/pkg/storaged/vgroup-details.jsx b/pkg/storaged/vgroup-details.jsx
index 644b52339909..dd3a9aede7b3 100644
--- a/pkg/storaged/vgroup-details.jsx
+++ b/pkg/storaged/vgroup-details.jsx
@@ -20,21 +20,24 @@
import cockpit from "cockpit";
import React from "react";
-import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
+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 { PlusIcon, MinusIcon } from "@patternfly/react-icons";
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import * as utils from "./utils.js";
import { fmt_to_fragments } from "utils.jsx";
import { StdDetailsLayout } from "./details.jsx";
import { SidePanel } from "./side-panel.jsx";
-import { VGroup } from "./content-views.jsx";
+import { VGroup, create_tabs, ThinPoolContent } from "./content-views.jsx";
import { StorageButton } from "./storage-controls.jsx";
import {
dialog_open, TextInput, SelectSpaces,
BlockingMessage, TeardownMessage,
init_active_usage_processes
} from "./dialog.jsx";
+import { BlockDetails } from "./block-details.jsx";
const _ = cockpit.gettext;
@@ -94,7 +97,9 @@ class VGroupSidebar extends React.Component {
}));
}
- if (pvols.length === 1) {
+ if (vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0) {
+ remove_excuse = _("Physical volumes can not be removed while a volume group is missing physical volumes.");
+ } else if (pvols.length === 1) {
remove_excuse = _("The last physical volume of a volume group cannot be removed.");
} else if (pvol.FreeSize < pvol.Size) {
if (pvol.Size <= vgroup.FreeSize)
@@ -218,7 +223,10 @@ export class VGroupDetails extends React.Component {
- vgroup_rename(client, vgroup)}>{_("Rename")}
+ 0
+ ? _("A volume group with missing physical volumes can not be renamed.")
+ : null }
+ onClick={() => vgroup_rename(client, vgroup)}>{_("Rename")}
{ "\n" }
vgroup_delete(client, vgroup)}>{_("Delete")}
>
@@ -242,10 +250,192 @@ export class VGroupDetails extends React.Component {
);
+ 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 = utils.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 utils.teardown_active_usage(client, usage)
+ .then(function () {
+ return utils.for_each_async(usage,
+ u => {
+ const lvm2 = client.blocks_lvm2[u.block.path];
+ const lvol = lvm2 && client.lvols[lvm2.LogicalVolume];
+ return lvol.Delete({ 'tear-down': { t: 'b', v: true } });
+ })
+ .then(() => vgroup.RemoveMissingPhysicalVolumes({}));
+ });
+ }
+ },
+ Inits: [
+ init_active_usage_processes(client, usage)
+ ]
+ });
+ }
+
+ let alert = null;
+ if (vgroup.MissingPhysicalVolumes && vgroup.MissingPhysicalVolumes.length > 0)
+ alert = (
+ {_("Dismiss")}}
+ title={_("This volume group is missing some physical volumes.")}>
+ {vgroup.MissingPhysicalVolumes.map(uuid => {uuid}
)}
+ );
+
const sidebar = ;
const content = ;
- return ;
+ return ;
}
}
+
+const ThinPoolDetails = ({ client, lvol }) => {
+ // XXX - mostly a copy of BlockDetails
+ const tabs = create_tabs(client, lvol, {});
+
+ 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} );
+ });
+
+ function is_container(r) {
+ return r.name == _("Logical volume") || r.name == _("Partition");
+ }
+
+ const content_renderers = tabs.renderers.filter(r => !is_container(r));
+ const vgroup = client.vgroups[lvol.VolumeGroup];
+
+ const header = (
+
+
+
+ {_("Pool for thinly provisioned logical volumes")}
+
+
+
+
+
+ {_("Stored on")}
+
+ cockpit.location.go(["vg", vgroup.Name])}>
+ {vgroup.Name}
+
+
+
+
+ { content_renderers.map(t => ) }
+
+
+ );
+
+ const content = ;
+
+ return ;
+};
+
+const InactiveVolumeDetails = ({ client, lvol }) => {
+ // XXX - mostly a copy of BlockDetails
+ const tabs = create_tabs(client, lvol, {});
+
+ 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} );
+ });
+
+ function is_container(r) {
+ return r.name == _("Logical volume") || r.name == _("Partition");
+ }
+
+ const content_renderers = tabs.renderers.filter(r => !is_container(r));
+ const vgroup = client.vgroups[lvol.VolumeGroup];
+
+ const header = (
+
+
+
+ {_("Inactive (or unsupported) logical volume")}
+
+
+
+
+
+ {_("Stored on")}
+
+ cockpit.location.go(["vg", vgroup.Name])}>
+ {vgroup.Name}
+
+
+
+
+ { content_renderers.map(t => ) }
+
+
+ );
+
+ return ;
+};
+
+export const LVolDetails = ({ client, lvol }) => {
+ const block = client.lvols_block[lvol.path];
+
+ if (lvol.Type == "pool") {
+ return ;
+ } else if (block) {
+ return ;
+ } else {
+ return ;
+ }
+};
diff --git a/pkg/storaged/vgroups-panel.jsx b/pkg/storaged/vgroups-panel.jsx
index 4392baee92a8..d4baf11f3b7e 100644
--- a/pkg/storaged/vgroups-panel.jsx
+++ b/pkg/storaged/vgroups-panel.jsx
@@ -38,7 +38,10 @@ function vgroup_row(client, path) {
job_path: path,
devname: "/dev/" + vgroup.Name + "/",
detail: fmt_size(vgroup.Size) + " " + _("LVM2 volume group"),
- go: () => cockpit.location.go(["vg", vgroup.Name])
+ size: vgroup.Size,
+ type: _("LVM2 volume group"),
+ go: () => cockpit.location.go(["vg", vgroup.Name]),
+ vgroup,
};
}
diff --git a/pkg/storaged/warnings.jsx b/pkg/storaged/warnings.jsx
index 3e50bd1cfe2a..bb8795257cb7 100644
--- a/pkg/storaged/warnings.jsx
+++ b/pkg/storaged/warnings.jsx
@@ -17,9 +17,13 @@
* along with Cockpit; If not, see .
*/
+import React from "react";
+import { ExclamationTriangleIcon, ExclamationCircleIcon } from "@patternfly/react-icons";
+
import { get_parent } from "./utils.js";
import { check_mismounted_fsys } from "./fsys-tab.jsx";
import { check_stratis_warnings } from "./stratis-details.jsx";
+import { check_partial_lvols } from "./lvol-tabs.jsx";
export function find_warnings(client) {
const path_warnings = { };
@@ -116,5 +120,16 @@ export function find_warnings(client) {
check_stratis_warnings(client, enter_warning);
+ for (const path in client.lvols) {
+ check_partial_lvols(client, path, enter_warning);
+ }
+
return path_warnings;
}
+
+export function warnings_icon(warnings) {
+ if (warnings.some(w => w.danger))
+ return ;
+ else
+ return ;
+}
diff --git a/test/common/storagelib.py b/test/common/storagelib.py
index 0fc55bb6c892..853a5775054e 100644
--- a/test/common/storagelib.py
+++ b/test/common/storagelib.py
@@ -92,6 +92,28 @@ def add_loopback_disk(self, size=50, name=None):
self.addCleanup(self.machine.execute, "umount {0} || true; rm $(losetup -n -O BACK-FILE -l {0}); until losetup -d {0}; do sleep 1; done".format(dev), timeout=10)
return dev
+ def add_targetd_loopback_disk(self, index, size=50):
+ """Add per-test loopback device that can be forcefully removed.
+ """
+
+ m = self.machine
+ model = f"disk{index}"
+ wwn = f"naa.5000{index:012x}"
+
+ m.execute(f"rm -f /var/tmp/targetd.{model}")
+ m.execute(f"targetcli /backstores/fileio create name={model} size={size}M file_or_dev=/var/tmp/targetd.{model}")
+ m.execute(f"targetcli /loopback create {wwn}")
+ m.execute(f"targetcli /loopback/{wwn}/luns create /backstores/fileio/{model}")
+
+ self.addCleanup(m.execute, f"targetcli /loopback delete {wwn}")
+ self.addCleanup(m.execute, f"targetcli /backstores/fileio delete {model}")
+ self.addCleanup(m.execute, f"rm -f /var/tmp/targetd.{model}")
+
+ dev = m.execute(f'for dev in /sys/block/*; do if [ -f $dev/device/model ] && [ "$(cat $dev/device/model | tr -d [:space:])" == "{model}" ]; then echo /dev/$(basename $dev); fi; done').strip()
+ if dev == "":
+ raise Error("Device not found")
+ return dev
+
def force_remove_disk(self, device):
"""Act like the given device gets physically removed.
@@ -140,8 +162,19 @@ def content_dropdown_action(self, index, title, isExpandable=True):
dropdown = self.content_row_tbody(index) + " tr td:last-child .pf-v5-c-dropdown"
else:
dropdown = "#detail-content > .pf-v5-c-card > div > table > :nth-child(%d)" % index + " td:last-child .pf-v5-c-dropdown"
- self.browser.click(dropdown + " button.pf-v5-c-dropdown__toggle")
- self.browser.click(dropdown + f" a:contains('{title}')")
+ btn = dropdown + f" a:contains('{title}')"
+
+ def step():
+ try:
+ if not self.browser.is_present(btn):
+ self.browser.click(dropdown + " button.pf-v5-c-dropdown__toggle")
+ self.browser.wait_visible(btn)
+ self.browser.click(btn)
+ return True
+ except Error:
+ return False
+
+ self.browser.wait(step)
def content_tab_expand(self, row_index, tab_index):
tab_btn = self.content_row_tbody(row_index) + " .pf-v5-c-tabs ul li:nth-child(%d) button" % tab_index
diff --git a/test/verify/check-storage-lvm2 b/test/verify/check-storage-lvm2
index f0d5cafa5cda..e733c26dfd3a 100755
--- a/test/verify/check-storage-lvm2
+++ b/test/verify/check-storage-lvm2
@@ -17,13 +17,29 @@
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see .
+import math
+import os
+import unittest
+
import storagelib
import testlib
+# We use basename a lot, let's reduce the noise
+def bn(path):
+ return os.path.basename(path)
+
+
@testlib.nondestructive
class TestStorageLvm2(storagelib.StorageCase):
+ def can_do_layouts(self):
+ return self.storaged_version >= [2, 10]
+
+ def skip_if_no_layouts(self):
+ if not self.can_do_layouts():
+ raise unittest.SkipTest("raid layouts not supported")
+
def testLvm(self):
m = self.machine
b = self.browser
@@ -143,6 +159,10 @@ class TestStorageLvm2(storagelib.StorageCase):
self.dialog({"name": "lvol1"})
self.content_row_wait_in_col(1, 1, "lvol1")
+ if self.can_do_layouts():
+ # check that it is stored on dev_2
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", bn(dev_2))
+
# grow it
self.content_tab_action(1, 1, "Grow")
self.dialog({"size": 30})
@@ -334,6 +354,504 @@ class TestStorageLvm2(storagelib.StorageCase):
self.content_row_wait_in_col(1, 1, "lvol0")
b.wait_not_in_text("#storage-detail", "snap0")
+ def testRaid(self):
+ m = self.machine
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_ram_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+ disk4 = self.add_loopback_disk()
+
+ # Make a volume group with four physical volumes
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_is_present('disks', disk4) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True,
+ disk4: True}})
+
+ self.addCleanup(m.execute, "vgremove --force vgroup0 2>/dev/null || true")
+ b.wait_in_text("#devices", "vgroup0")
+
+ # Make a raid5 on three PVs, using about half of each
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+ b.click("button:contains(Create new logical volume)")
+ self.dialog_wait_open()
+ self.dialog_wait_val("name", "lvol0")
+ self.dialog_set_val("purpose", "block")
+ self.dialog_set_val("layout", "raid5")
+ self.dialog_set_val("pvs", {disk1: False})
+ self.dialog_set_val("size", 40)
+ b.assert_pixels("#dialog", "create-raid5")
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ self.content_tab_wait_in_info(1, 1, "Layout", "Distributed parity (RAID 5)")
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk2))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk3))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk4))
+
+ tab = self.content_tab_expand(1, 1)
+ b.assert_pixels(tab, "raid5-tab")
+
+ # Make linear volume to fully use second PV
+
+ b.click("button:contains(Create new logical volume)")
+ self.dialog(expect={"name": "lvol1"},
+ values={"purpose": "block",
+ "layout": "linear",
+ "pvs": {disk1: False, disk3: False, disk4: False}})
+ self.content_row_wait_in_col(2, 1, "lvol1")
+ self.content_tab_wait_in_info(2, 1, "Physical volumes", bn(disk2))
+
+ # Grow raid5 to about maximum
+ self.content_tab_action(1, 1, "Grow")
+ self.dialog_wait_open()
+ self.dialog_set_val("size", 80)
+ b.assert_pixels("#dialog", "grow-raid5")
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk1))
+
+ # Check that each PV is used exactly once.
+ tab = self.content_tab_expand(1, 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk2)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk3)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk4)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk1)}')", 1)
+ b.assert_pixels(tab, "raid5-tab2")
+
+ def testRaidRepair(self):
+ m = self.machine
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_ram_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+ disk4 = self.add_loopback_disk()
+
+ # Make a volume group with three physical volumes
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True}})
+
+ self.addCleanup(m.execute, "vgremove --force vgroup0 2>/dev/null || true")
+ b.wait_in_text("#devices", "vgroup0")
+
+ # Make a raid5 on the three PVs
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+ b.click("button:contains(Create new logical volume)")
+ self.dialog(expect={"name": "lvol0"},
+ values={"purpose": "block",
+ "layout": "raid5"})
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ self.content_tab_wait_in_info(1, 1, "Layout", "Distributed parity (RAID 5)")
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk1))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk2))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk3))
+
+ # Kill one PV
+
+ self.force_remove_disk(disk1)
+
+ b.wait_in_text(".pf-v5-c-alert", "This volume group is missing some physical volumes.")
+ b.wait_visible("button:contains(Rename):disabled")
+
+ self.content_tab_wait_in_info(1, 1, "Stripes", "This logical volume has lost some of its physical volumes but has not lost any data yet.")
+ self.content_tab_wait_in_info(1, 1, "Stripes", cond=lambda sel: bn(disk1) not in b.text(sel))
+
+ # Repair with fourth
+
+ self.content_row_action(1, "Repair")
+ self.dialog_wait_open()
+ b.wait_in_text("#dialog", "There is not enough space available")
+ self.dialog_cancel()
+ self.dialog_wait_close()
+
+ b.click('#detail-sidebar .pf-v5-c-card__header button')
+ self.dialog_wait_open()
+ self.dialog_set_val('disks', {disk4: True})
+ self.dialog_apply()
+ self.dialog_wait_close()
+ b.wait_in_text("#detail-sidebar", disk4)
+
+ self.content_row_action(1, "Repair")
+ self.dialog_wait_open()
+ self.dialog_apply()
+ self.dialog_wait_error("pvs", "An additional 46.1 MB must be selected")
+ self.dialog_set_val("pvs", {disk4: True})
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ self.content_tab_wait_in_info(1, 1, "Stripes",
+ cond=lambda sel: "This logical volume has lost some" not in b.text(sel))
+ self.content_tab_wait_in_info(1, 1, "Stripes", bn(disk4))
+
+ # Dismiss alert
+
+ b.click(".pf-v5-c-alert button:contains(Dismiss)")
+ self.dialog({})
+ b.wait_not_present(".pf-v5-c-alert")
+
+ def testBrokenLinear(self):
+ m = self.machine
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_ram_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+
+ # Make a volume group with three physical volumes
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True}})
+
+ self.addCleanup(m.execute, "vgremove --force vgroup0 2>/dev/null || true")
+ b.wait_in_text("#devices", "vgroup0")
+
+ # Make a linear volume on two of them
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+ b.click("button:contains(Create new logical volume)")
+ self.dialog(expect={"name": "lvol0"},
+ values={"purpose": "block",
+ "layout": "linear",
+ "pvs": {disk2: False}})
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", bn(disk1))
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", bn(disk3))
+
+ # Kill one PV
+
+ self.force_remove_disk(disk1)
+ b.wait_in_text(".pf-v5-c-alert", "This volume group is missing some physical volumes.")
+ b.wait_visible("button:contains(Rename):disabled")
+
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", "This logical volume has lost some of its physical volumes and can no longer be used.")
+ self.content_tab_wait_in_info(1, 1, "Physical volumes", cond=lambda sel: bn(disk1) not in b.text(sel))
+
+ # Dismiss alert, this will delete the volume
+
+ b.click(".pf-v5-c-alert button:contains(Dismiss)")
+ self.dialog_wait_open()
+ b.wait_in_text("#dialog", "/dev/vgroup0/lvol0")
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ b.wait_not_present(".pf-v5-c-alert")
+ b.wait_in_text("#detail-content", "No logical volumes")
+
+ def testMaxLayoutSizes(self):
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ # Make sure that Cockpit gets the computation of the maximum
+ # size right.
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_loopback_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+ disk4 = self.add_loopback_disk()
+ disk5 = self.add_loopback_disk()
+ disk6 = self.add_loopback_disk()
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_is_present('disks', disk4) and
+ self.dialog_is_present('disks', disk5) and
+ self.dialog_is_present('disks', disk6) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True,
+ disk4: True,
+ disk5: True,
+ disk6: True}})
+ self.addCleanup(self.machine.execute, "vgremove --force vgroup0 2>/dev/null || true")
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+
+ def mb(size):
+ if size < 100e6:
+ return str(round(size / 1e6, 1)) + " MB"
+ else:
+ return str(round(size / 1e6)) + " MB"
+
+ def test(layout, expected_size):
+ b.click("button:contains(Create new logical volume)")
+ self.dialog_wait_open()
+ self.dialog_wait_val("name", "lvol0")
+ self.dialog_set_val("purpose", "block")
+ self.dialog_set_val("layout", layout)
+ if layout == "raid10":
+ self.dialog_set_val("pvs", {disk6: False})
+ self.dialog_apply()
+ self.dialog_wait_error("pvs", "an even number")
+ self.dialog_set_val("pvs", {disk6: True})
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ field = "Physical volumes" if layout.startswith("linear") else "Stripes"
+
+ self.content_tab_wait_in_info(1, 1, field, bn(disk1))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk2))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk3))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk4))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk5))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk6))
+
+ self.content_tab_wait_in_info(1, 1, "Size", mb(expected_size))
+
+ self.content_dropdown_action(1, "Delete")
+ self.confirm()
+ b.wait_not_in_text("#detail-content", "/dev/vgroup0/lvol0")
+
+ ext_size = 4 * 1024 * 1024
+ pv_size = math.floor(50e6 / ext_size) * ext_size
+ n_pvs = 6
+
+ test("linear", n_pvs * pv_size)
+ test("raid0", n_pvs * pv_size)
+ test("raid1", pv_size - ext_size)
+ test("raid10", (n_pvs / 2) * (pv_size - ext_size))
+ test("raid5", (n_pvs - 1) * (pv_size - ext_size))
+ test("raid6", (n_pvs - 2) * (pv_size - ext_size))
+
+ def testMaxLayoutGrowth(self):
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ # Make sure that Cockpit gets the computation of the maximum
+ # size right when growing a logical volume.
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_loopback_disk()
+ disk2 = self.add_loopback_disk()
+ disk3 = self.add_loopback_disk()
+ disk4 = self.add_loopback_disk()
+ disk5 = self.add_loopback_disk()
+ disk6 = self.add_loopback_disk()
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_is_present('disks', disk4) and
+ self.dialog_is_present('disks', disk5) and
+ self.dialog_is_present('disks', disk6) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True,
+ disk4: True,
+ disk5: True,
+ disk6: True}})
+ self.addCleanup(self.machine.execute, "vgremove --force vgroup0 2>/dev/null || true")
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+
+ def mb(size):
+ if size < 100e6:
+ return str(round(size / 1e6, 1)) + " MB"
+ else:
+ return str(round(size / 1e6)) + " MB"
+
+ def test(layout, expected_size):
+ b.click("button:contains(Create new logical volume)")
+ self.dialog(expect={"name": "lvol0"},
+ values={"purpose": "block",
+ "layout": layout,
+ "size": round(expected_size / 2e6)})
+
+ self.content_row_wait_in_col(1, 1, "lvol0")
+
+ field = "Stripes"
+
+ self.content_tab_wait_in_info(1, 1, field, bn(disk1))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk2))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk3))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk4))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk5))
+ self.content_tab_wait_in_info(1, 1, field, bn(disk6))
+
+ # Grow to max with default pvs
+ self.content_tab_action(1, 1, "Grow")
+ self.dialog_wait_open()
+ slider = self.dialog_field("size") + " .pf-v5-c-slider .pf-v5-c-slider__rail"
+ width = b.call_js_func('(function (sel) { return ph_find(sel).offsetWidth; })', slider)
+ b.mouse(slider, "click", width, 0)
+ self.dialog_apply()
+ self.dialog_wait_close()
+
+ tab = self.content_tab_expand(1, 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk1)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk2)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk3)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk4)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk5)}')", 1)
+ b.wait_js_func("ph_count_check", tab + f" .storage-stripe-pv-box-dev:contains('{bn(disk6)}')", 1)
+
+ self.content_tab_wait_in_info(1, 1, "Size", mb(expected_size))
+
+ self.content_dropdown_action(1, "Delete")
+ self.confirm()
+ b.wait_not_in_text("#detail-content", "/dev/vgroup0/lvol0")
+
+ ext_size = 4 * 1024 * 1024
+ pv_size = math.floor(50e6 / ext_size) * ext_size
+ n_pvs = 6
+
+ test("raid0", n_pvs * pv_size)
+ test("raid1", pv_size - ext_size)
+ test("raid10", (n_pvs / 2) * (pv_size - ext_size))
+ test("raid5", (n_pvs - 1) * (pv_size - ext_size))
+ test("raid6", (n_pvs - 2) * (pv_size - ext_size))
+
+ def testDegradation(self):
+ b = self.browser
+
+ self.skip_if_no_layouts()
+
+ # Make one (very small) logical volume for each RAID type and
+ # then break them.
+
+ self.login_and_go("/storage")
+
+ disk1 = self.add_targetd_loopback_disk(index=1, size=100)
+ disk2 = self.add_targetd_loopback_disk(index=2, size=100)
+ disk3 = self.add_targetd_loopback_disk(index=3, size=100)
+ disk4 = self.add_targetd_loopback_disk(index=4, size=100)
+ disk5 = self.add_targetd_loopback_disk(index=5, size=100)
+
+ with b.wait_timeout(60):
+ self.dialog_with_retry(trigger=lambda: self.devices_dropdown("Create LVM2 volume group"),
+ expect=lambda: (self.dialog_is_present('disks', disk1) and
+ self.dialog_is_present('disks', disk2) and
+ self.dialog_is_present('disks', disk3) and
+ self.dialog_is_present('disks', disk4) and
+ self.dialog_is_present('disks', disk5) and
+ self.dialog_check({"name": "vgroup0"})),
+ values={"disks": {disk1: True,
+ disk2: True,
+ disk3: True,
+ disk4: True,
+ disk5: True}})
+ self.addCleanup(self.machine.execute, "vgremove --force vgroup0 2>/dev/null || true")
+
+ b.click('.sidepanel-row:contains("vgroup0")')
+ b.wait_visible('#storage-detail')
+
+ def create(row, layout, expected_name):
+ b.click("button:contains(Create new logical volume)")
+ self.dialog_wait_open()
+ self.dialog_wait_val("name", expected_name)
+ self.dialog_set_val("layout", layout)
+ if layout == "raid10":
+ self.dialog_set_val("pvs", {disk5: False})
+ elif layout == "linear":
+ self.dialog_set_val("pvs", {disk2: False, disk3: False, disk4: False, disk5: False})
+ self.dialog_set_val("size", 10)
+ self.dialog_apply()
+ self.dialog_wait_close()
+ self.content_row_wait_in_col(row, 1, expected_name)
+
+ create(1, "linear", "lvol0")
+ create(2, "raid0", "lvol1")
+ create(3, "raid1", "lvol2")
+ create(4, "raid10", "lvol3")
+ create(5, "raid5", "lvol4")
+ create(6, "raid6", "lvol5")
+
+ def wait_msg(row, msg):
+ self.content_tab_wait_in_info(row, 1, "Physical volumes" if row == 1 else "Stripes", msg)
+
+ def wait_partial(row):
+ wait_msg(row, "This logical volume has lost some of its physical volumes and can no longer be used.")
+
+ def wait_degraded(row):
+ wait_msg(row, "This logical volume has lost some of its physical volumes but has not lost any data yet.")
+
+ def wait_maybe_partial(row):
+ wait_msg(row, "This logical volume has lost some of its physical volumes but might not have lost any data yet.")
+
+ self.force_remove_disk(disk1)
+
+ wait_partial(1) # linear is broken now
+ wait_partial(2) # striped as well
+ wait_degraded(3) # mirror is fine
+ wait_degraded(4) # striped mirror as well
+ wait_degraded(5) # raid5 and ...
+ wait_degraded(6) # ... raid6 are doing their job
+
+ self.force_remove_disk(disk2)
+
+ wait_degraded(3) # mirror is still fine
+ wait_maybe_partial(4) # striped mirror is not sure anymore
+ wait_partial(5) # raid5 is now toast
+ wait_degraded(6) # but raid6 still hangs on
+
+ self.force_remove_disk(disk3)
+
+ wait_degraded(3) # mirror is _still_ fine
+ wait_partial(4) # striped mirror has lost more than half and is kaputt
+ wait_partial(6) # raid6 is finally toast as well
+
if __name__ == '__main__':
testlib.test_main()