From ed6b0fac976b1ae899308ea9daf5fb663bd18856 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Fri, 25 Aug 2023 13:41:36 +0300 Subject: [PATCH] WIP - unified overview --- pkg/storaged/block-details.jsx | 21 +++- pkg/storaged/client.js | 22 +++- pkg/storaged/content-views.jsx | 200 +++++++++++++++++++++++------- pkg/storaged/details.jsx | 8 +- pkg/storaged/drives-panel.jsx | 4 +- pkg/storaged/iscsi-panel.jsx | 35 +++++- pkg/storaged/mdraid-details.jsx | 22 +++- pkg/storaged/mdraids-panel.jsx | 6 +- pkg/storaged/nfs-details.jsx | 12 +- pkg/storaged/nfs-panel.jsx | 74 +++++++---- pkg/storaged/overviewx.jsx | 170 +++++++++++++++++++++++++ pkg/storaged/storage-controls.jsx | 5 +- pkg/storaged/storage.scss | 18 ++- pkg/storaged/storaged.jsx | 3 +- pkg/storaged/stratis-details.jsx | 39 +++++- pkg/storaged/stratis-panel.jsx | 5 +- pkg/storaged/things-panel.jsx | 5 +- pkg/storaged/vdos-panel.jsx | 2 + pkg/storaged/vgroups-panel.jsx | 5 +- 19 files changed, 563 insertions(+), 93 deletions(-) create mode 100644 pkg/storaged/overviewx.jsx diff --git a/pkg/storaged/block-details.jsx b/pkg/storaged/block-details.jsx index f9deed2b6ddc..7afdb504ac60 100644 --- a/pkg/storaged/block-details.jsx +++ b/pkg/storaged/block-details.jsx @@ -22,16 +22,19 @@ import React from "react"; import { Card, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js"; import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js"; +import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; import * as utils from "./utils.js"; import { StdDetailsLayout } from "./details.jsx"; -import * as Content from "./content-views.jsx"; +import { create_tabs } from "./content-views.jsx"; const _ = cockpit.gettext; export class BlockDetails extends React.Component { render() { const block = this.props.block; + const tabs = create_tabs(this.props.client, block, {}); + console.log("T", tabs); const header = ( @@ -51,7 +54,21 @@ export class BlockDetails extends React.Component { ); - const content = ; + function make_tab(t) { + return ( + + {t.name} + + + + + ); + } + + const content = ( + + {tabs.renderers.map(make_tab)} + ); return ; } diff --git a/pkg/storaged/client.js b/pkg/storaged/client.js index 2ce4cbdcc7ac..f7c8d51b1b63 100644 --- a/pkg/storaged/client.js +++ b/pkg/storaged/client.js @@ -206,7 +206,7 @@ function is_multipath_master(block) { } function update_indices() { - let path, block, mdraid, vgroup, pvol, lvol, pool, blockdev, fsys, part, i; + let path, block, mdraid, vgroup, pvol, lvol, pool, blockdev, fsys, part, session, i; client.broken_multipath_present = false; client.drives_multipath_blocks = { }; @@ -457,6 +457,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 57a32b0e0cc8..d6b22a3cd08f 100644 --- a/pkg/storaged/content-views.jsx +++ b/pkg/storaged/content-views.jsx @@ -49,6 +49,7 @@ import { BlockVolTab, PoolVolTab, VDOPoolTab } from "./lvol-tabs.jsx"; import { PartitionTab } from "./part-tab.jsx"; import { SwapTab } from "./swap-tab.jsx"; import { UnrecognizedTab } from "./unrecognized-tab.jsx"; +import { vgroup_rename, vgroup_delete } from "./vgroup-details.jsx"; const _ = cockpit.gettext; @@ -106,7 +107,7 @@ export function set_crypto_auto_option(block, flag) { return set_crypto_options(block, null, flag, null, null); } -function create_tabs(client, target, options) { +export function create_tabs(client, target, options) { function endsWith(str, suffix) { return str.indexOf(suffix, str.length - suffix.length) !== -1; } @@ -131,6 +132,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; @@ -140,17 +143,28 @@ function create_tabs(client, target, options) { warnings = warnings.concat(client.path_warnings[content_block.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 { + tab_actions.push({title}); + tab_menu_actions.push({ title, func, only_narrow: true }); + } } 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 { + tab_actions.push({title}); + tab_menu_danger_actions.push({ title, func, only_narrow: true }); + } } function add_menu_action(title, func) { @@ -164,6 +178,11 @@ 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); @@ -188,7 +207,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"), { @@ -224,7 +243,7 @@ function create_tabs(client, target, options) { } } - if (options.is_partition) { + if (client.blocks_part[block.path]) { add_tab(_("Partition"), PartitionTab); } @@ -275,7 +294,7 @@ function create_tabs(client, target, options) { return; dialog_open({ - Title: _("Unlock"), + Title: _("Unlock $0", target_name), Fields: [ PassInput("passphrase", _("Passphrase"), {}) ], @@ -296,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")); } } @@ -312,7 +331,7 @@ function create_tabs(client, target, options) { function create_snapshot() { 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 }), @@ -331,7 +350,7 @@ function create_tabs(client, target, options) { if (lvol.Active) { add_menu_action(_("Deactivate"), deactivate); } else { - add_action(_("Activate"), activate); + add_action(_("Activate"), activate, _("not active")); } } if (client.lvols[lvol.ThinPool]) { @@ -363,29 +382,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, @@ -423,12 +440,13 @@ 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 @@ -513,6 +531,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, @@ -558,6 +579,15 @@ function append_row(client, rows, level, key, name, desc, tabs, job_object, opti 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: ( @@ -567,7 +597,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 ? @@ -580,7 +610,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 }); } @@ -588,7 +619,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) { @@ -602,20 +634,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")} - - ); + let btn, item, menu; - const item = ( - - {_("Create partition")} - ); - - const menu = ; + if (options.unified) { + btn = null; + item = ( + + {_("Create partition")} + ); + menu = ; + } else { + btn = ( + + {_("Create partition")} + ); + item = ( + + {_("Create partition")} + ); + menu = ; + } const cols = [ _("Free space"), @@ -627,7 +668,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, }); } @@ -667,7 +712,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; } @@ -728,6 +774,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; @@ -750,14 +810,28 @@ 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} - @@ -783,7 +857,8 @@ 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); + append_row(client, rows, level, block.path, lvol.Name, desc, tabs, block.path, + {...options, go: () => cockpit.location.go([utils.block_name(block).replace(/^\/dev\//, "")])}); } function append_logical_volume(client, rows, level, lvol, options) { @@ -801,9 +876,9 @@ function append_logical_volume(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 @@ -822,12 +897,13 @@ function append_logical_volume(client, rows, level, lvol, options) { export function vgroup_content_rows(client, vgroup, options) { const rows = []; + const go = () => cockpit.location.go(["vg", vgroup.Name]); const isVDOPool = lvol => Object.keys(client.vdo_vols).some(v => client.vdo_vols[v].VDOPool == lvol.path); (client.vgroups_lvols[vgroup.path] || []).forEach(lvol => { // Don't display VDO pool volumes as separate entities; they are an internal implementation detail and have no actions if (lvol.ThinPool == "/" && lvol.Origin == "/" && !isVDOPool(lvol)) - append_logical_volume(client, rows, 0, lvol, options); + append_logical_volume(client, rows, options.level || 0, lvol, { go, ...options }); }); return rows; } @@ -869,7 +945,7 @@ function create_logical_volume(client, vgroup) { purposes.push({ value: "vdo", title: _("VDO filesystem volume (compression/deduplication)") }); dialog_open({ - Title: _("Create logical volume"), + Title: cockpit.format(_("Create logical volume in $0"), vgroup.Name), Fields: [ TextInput("name", _("Name"), { @@ -986,6 +1062,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(); @@ -1013,6 +1109,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 ( @@ -1021,9 +1130,10 @@ 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..67c923198e09 100644 --- a/pkg/storaged/details.jsx +++ b/pkg/storaged/details.jsx @@ -85,14 +85,16 @@ export class Details extends React.Component { let body = null; let name = this.props.name; - if (this.props.type == "block") { + 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) { + } + } else if (this.props.type == "block") { + const block = client.slashdevs_block["/dev/" + this.props.name]; + if (block) { name = utils.block_name(block); body = ; } 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/iscsi-panel.jsx b/pkg/storaged/iscsi-panel.jsx index 8a97649a20bb..00354aeab8b1 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/mdraid-details.jsx b/pkg/storaged/mdraid-details.jsx index 9d11a27dd0f3..e800a20cfa6e 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 ecf247bb670c..2ac41bf5971b 100644 --- a/pkg/storaged/nfs-details.jsx +++ b/pkg/storaged/nfs-details.jsx @@ -29,7 +29,7 @@ import { import * as format from "./format-dialog.jsx"; import { StdDetailsLayout } from "./details.jsx"; -import { StorageButton, StorageUsageBar } from "./storage-controls.jsx"; +import { StorageButton, StorageUsageBar, StorageMenuItem } from "./storage-controls.jsx"; 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 f84abdda3222..c5d27e24848b 100644 --- a/pkg/storaged/nfs-panel.jsx +++ b/pkg/storaged/nfs-panel.jsx @@ -23,39 +23,74 @@ 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 ? - : _("Not 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: [ + { title: server + " " + remote_dir }, + { title: entry.fields[1] }, + { + title: entry.mounted + ? + : _("Not mounted") } ] }; - } + } - 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; @@ -89,7 +115,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/storage-controls.jsx b/pkg/storaged/storage-controls.jsx index d92ca08c5a11..abdd6ebdc755 100644 --- a/pkg/storaged/storage-controls.jsx +++ b/pkg/storaged/storage-controls.jsx @@ -241,7 +241,10 @@ export const StorageBarMenu = ({ label, isKebab, onlyNarrow, menuItems }) => { return null; function onToggle(event, isOpen) { - event.stopPropagation(); + // 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); } diff --git a/pkg/storaged/storage.scss b/pkg/storaged/storage.scss index d22cb476533a..c34ac1ddce7b 100644 --- a/pkg/storaged/storage.scss +++ b/pkg/storaged/storage.scss @@ -205,12 +205,28 @@ tr[class*="content-level-"] { } } -@for $i from 1 through 10 { +#unified tr[class*="content-level-"] { + --multiplier: 0; + --offset: calc(var(--pf-v5-global--spacer--lg) * var(--multiplier)); + + > td:first-child { + position: relative; + inset-inline-start: var(--offset); + } +} + +@for $i from 0 through 10 { tr.content-level-#{$i} { --multiplier: #{$i}; } } +@for $i from 0 through 10 { + #unified tr.content-level-#{$i} { + --multiplier: #{$i}; + } +} + .tab-actions { float: inline-end; margin-block-start: -15px; diff --git a/pkg/storaged/storaged.jsx b/pkg/storaged/storaged.jsx index 4a04b8e381e9..65c6a832a20b 100644 --- a/pkg/storaged/storaged.jsx +++ b/pkg/storaged/storaged.jsx @@ -31,6 +31,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 +89,7 @@ class StoragePage extends React.Component { return ( <> - {detail || } + {detail || } ); } diff --git a/pkg/storaged/stratis-details.jsx b/pkg/storaged/stratis-details.jsx index 43bb0b2ffc9f..106e52e85ac8 100644 --- a/pkg/storaged/stratis-details.jsx +++ b/pkg/storaged/stratis-details.jsx @@ -265,6 +265,7 @@ export function stratis_content_rows(client, pool, options) { const stats = client.stratis_pool_stats[pool.path]; const forced_options = ["x-systemd.requires=stratis-fstab-setup@" + pool.Uuid + ".service"]; const managed_fsys_sizes = client.features.stratis_managed_fsys_sizes && !pool.Overprovisioning; + function render_fsys(fsys, offset) { const block = client.slashdevs_block[fsys.Devnode]; @@ -443,8 +444,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) @@ -479,10 +484,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(["pool", pool.Uuid]) }; } @@ -496,7 +507,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 +635,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] && diff --git a/pkg/storaged/stratis-panel.jsx b/pkg/storaged/stratis-panel.jsx index 9306ee7c3789..78cd6e1e5a33 100644 --- a/pkg/storaged/stratis-panel.jsx +++ b/pkg/storaged/stratis-panel.jsx @@ -59,8 +59,11 @@ function stratis_pool_row(client, path) { 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/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/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, }; }