* Copyright (C) 2023 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import React, { useState, useRef } from "react";
import client from "./client";
import { useEvent } from "hooks.js";
import { AlertGroup } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { Card, CardHeader, CardTitle, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
import { DropdownGroup, DropdownList } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js";
import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye/index.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
import { EmptyState, EmptyStateBody } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
import { ExclamationTriangleIcon, ExclamationCircleIcon, HelpIcon } from "@patternfly/react-icons";
import { Page, PageBreadcrumb, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { Icon } from "@patternfly/react-core/dist/esm/components/Icon";
import { Popover } from "@patternfly/react-core/dist/esm/components/Popover";
import {
decode_filename, block_short_name, fmt_size,
reset_available_spaces
} from "./utils.js";
import { StorageButton, StorageBarMenu, StorageMenuItem, StorageSize } from "./storage-controls.jsx";
import { MultipathAlert } from "./multipath.jsx";
import { JobsPanel } from "./jobs-panel.jsx";
import { Truncate } from "../lib/cockpit-components-truncate.jsx";
const _ = cockpit.gettext;
This is the central hub of the Storage application. It implements a
couple of concepts: "page", "card", "crossref", and "action". It
also implements the React components that construct the actual UI
out of them.
On every change of the client, a tree of pages is constructed. Each
page has a list of cards on it. The cards each have a list of
actions. In addition to that, there are a few crossrefs (cross
references) between pages that don't follow the tree structure.
The tree of pages starts with "make_overview_page" in
overview/overview.jsx. That function creates the data structures
that represent the overview page (using functions exported from
this file), and is also responsible for kicking off the creation of
its child pages. (And each page construction function of course
also creates the cards and actions for that page).
Pages can be shown by themselves, or be part of various tables in
other pages. For example, each row in the table in the overview
page represents another page, and clicking on it will navigate to
that page. (In fact, the overview table shows the whole tree. Other
pages show subsets of the tree in their tables.)
Most of the complications with pages and cards are there to support
putting pages into tables. Without having to do that, we wouldn't
need the pages and cards abstractions at all.
A page derives its "ID", "Type", "Location", and "Size" properties
from its cards in various ad-hoc and arguably too complex
ways. This is seems necessary since the cards that end up together
on a page can vary significantly depending on the actual storage
configuration of the machine.
For example, a MDRAID device might have a encrypted LVM2 physical
volume directly on it, or it might have a partition table with a
PV, in which case the card for the PV appears on another page
together with a card for the partition.
Cards are constructed with a call like this:
const card = new_card({
next: next_card,
title: ...,
component: CardComponent,
props: { react_props... }
});
A page is constructed with a call like this:
new_page(parent_page, first_card, { options... });
A call to "new_page" will implicitly build the tree by inserting
the new_page into the children of "parent_page".
The main React component for this application is StoragePage. It
will find the correct page for the browser URL and display it,
together with all the trimmings like the breadcrumb trail.
The page will be filled with the React components specified by its
cards. Such a component should use StorageCard to get the standard
look of all cards, and to work well with the test helpers.
Tables of pages are handled by the PageTable component exported
from this file or, more likely, its specialized version
ChildrenTable.
A ChildrenTable shows all direct and indirect children of a given
page, by using a PageTable appropriately. A raw PageTable can also
show cross references.
A cross reference associates a page with an arbitrary key. For
example, a page that represents a LVM2 physical volume will
associate itself with the DBusProxy for its volume group. The page
for the volume group will then retrieve all pages associated with
its DBusProxy and put them into a table to show all its physical
volumes.
When a page appears in such a crossref table, it has dedicated
actions, a dedicated size, and generally looks different from when
it appears in a "normal" children's table. This is another source of
complication of how cards and pages interact.
*/
let pages = null;
let crossrefs = null;
export const PAGE_CATEGORY_PHYSICAL = 1;
export const PAGE_CATEGORY_VIRTUAL = 2;
export const PAGE_CATEGORY_NETWORK = 3;
export function reset_pages() {
pages = new Map();
crossrefs = new Map();
reset_available_spaces();
}
function name_from_card(card) {
if (!card)
return null;
return name_from_card(card.next) || card.page_name;
}
function icon_from_card(card) {
if (!card)
return null;
return icon_from_card(card.next) || card.page_icon;
}
function category_from_card(card) {
if (!card)
return null;
return category_from_card(card.next) || card.page_category;
}
function key_from_card(card) {
if (!card)
return null;
return key_from_card(card.next) || card.page_key;
}
function location_from_card(card) {
if (!card)
return null;
return location_from_card(card.next) || card.page_location;
}
function size_from_card(card) {
if (!card)
return null;
if (card.page_size)
return card.page_size;
return size_from_card(card.next);
}
export function new_page(parent, card, options) {
const page = {
location: location_from_card(card),
name: name_from_card(card),
icon: icon_from_card(card),
category: category_from_card(card),
key: key_from_card(card),
parent,
children: [],
card,
options: options || {},
};
page.columns = [
card.title,
card.location,
size_from_card(card),
];
if (parent)
parent.children.push(page);
while (card) {
card.page = page;
card = card.next;
}
if (page.location) {
pages.set(JSON.stringify(page.location), page);
if (page.location.length === 0) {
not_found_page.parent = page;
}
}
return page;
}
export function new_card({
title, location, next,
type_extra, id_extra,
page_name, page_icon, page_category, page_key, page_location, page_size,
page_block,
for_summary,
has_warning, has_danger, job_path,
component, props,
actions,
}) {
if (page_block) {
page_location = [block_location(page_block)];
page_name = block_short_name(page_block);
page_size = page_block.Size;
job_path = page_block.path;
}
return {
title,
location,
next,
type_extra,
page_name,
page_icon,
page_category: page_category || PAGE_CATEGORY_PHYSICAL,
page_key,
page_location,
page_size,
for_summary,
component,
props,
has_warning,
has_danger,
job_path,
actions: actions ? actions.filter(a => !!a) : null,
id_extra,
};
}
export function register_crossref(crossref) {
const val = crossrefs.get(crossref.key) || [];
crossref.actions = crossref.actions ? crossref.actions.filter(a => !!a) : null;
val.push(crossref);
crossrefs.set(crossref.key, val);
}
export function get_crossrefs(key) {
return crossrefs.get(key);
}
*
* We have a special "not found" page that is returned when there is
* no real page at the given location.
*/
const NotFoundCard = ({ card }) => {
return (
<StorageCard card={card}>
<CardBody>
{_("Not found")}
</CardBody>
</StorageCard>);
};
const not_found_page = new_page(null, new_card({ page_name: _("Not found"), component: NotFoundCard }));
export function get_page_from_location(location) {
return pages?.get(JSON.stringify(location)) || not_found_page;
}
*/
export function navigate_away_from_card(card) {
if (!card)
return;
const loc = cockpit.location;
const page = card.page;
if (page.parent && JSON.stringify(loc.path) === JSON.stringify(page.location))
loc.go(page.parent.location);
}
export function navigate_to_new_card_location(card, location) {
if (!card)
return;
const loc = cockpit.location;
const page = card.page;
if (JSON.stringify(loc.path) === JSON.stringify(page.location))
loc.go(location);
}
function make_menu_item(action) {
return <StorageMenuItem key={action.title} onClick={() => action.action(true)}
danger={action.danger} excuse={action.excuse}>
{action.title}
</StorageMenuItem>;
}
function make_page_kebab(page) {
const items = [];
function card_item_group(card) {
const a = card.actions || [];
if (a.length > 0) {
let result = <DropdownList key={items.length}>{a.map(make_menu_item)}</DropdownList>;
if (card.title) {
result = <DropdownGroup key={items.length} label={card.title} className="storage-menu-title">
{result}
</DropdownGroup>;
}
return result;
} else
return null;
}
let c = page.card;
while (c) {
const g = card_item_group(c);
if (g) {
if (items.length > 0)
items.push(<Divider key={"div" + items.length} />);
items.push(g);
}
c = c.next;
}
if (items.length === 0)
return null;
return <StorageBarMenu menuItems={items} isKebab />;
}
function make_actions_kebab(actions) {
if (!actions || actions.length === 0)
return null;
return <StorageBarMenu menuItems={actions.map(make_menu_item)} isKebab />;
}
export const Actions = ({ actions, onlyMenu }) => {
const narrow = useIsNarrow();
function for_menu(action) {
if (onlyMenu || narrow)
return true;
if (action.danger)
return true;
return false;
}
const buttons = [];
const items = [];
if (!actions)
return null;
for (const action of actions) {
if (for_menu(action))
items.push(make_menu_item(action));
else
buttons.push(
<StorageButton key={action.title} onClick={() => action.action(false)}
kind={action.danger ? "danger" : null} excuse={action.excuse}
spinner={action.spinner}>
{action.title}
</StorageButton>);
}
if (items.length > 0)
buttons.push(<StorageBarMenu key="menu" menuItems={items} isKebab />);
return buttons;
};
function page_type_extra(page) {
const extra = [];
let c = page.card;
while (c) {
if (c.type_extra)
extra.push(c.type_extra);
c = c.next;
}
return extra;
}
function page_type(page) {
const type = page.card.title;
const extra = page_type_extra(page);
if (extra.length > 0)
return type + " (" + extra.join(", ") + ")";
else
return type;
}
function page_block_summary_1(page) {
let description = null;
const extra = [];
for (let card = page.card; card; card = card.next) {
if (card.for_summary) {
description = card.id_extra || card.title;
break;
}
if (card.type_extra)
extra.push(card.type_extra);
}
if (description && extra.length > 0)
description += " (" + extra.join(", ") + ")";
return description;
}
function page_block_summary(page) {
const desc1 = page_block_summary_1(page);
const desc2 = page.parent && page.parent.parent && page_block_summary_1(page.parent);
if (desc1 && desc2)
return desc1 + " - " + desc2;
else
return desc1 || desc2;
}
let narrow_query = null;
export const useIsNarrow = (onChange) => {
if (!narrow_query) {
const val = window.getComputedStyle(window.document.body).getPropertyValue("--pf-t--global--breakpoint--md");
narrow_query = window.matchMedia(`(max-width: ${val})`);
}
useEvent(narrow_query, "change", onChange);
return narrow_query.matches;
};
export const PageTable = ({ emptyCaption, aria_label, pages, crossrefs, sorted, show_icons }) => {
const [collapsed, setCollapsed] = useState(true);
const firstKeys = useRef(false);
const narrow = useIsNarrow(() => { firstKeys.current = false });
let rows = [];
const row_keys = new Set();
function make_row(page, crossref, level, border, key) {
function card_has_danger(card) {
if (card)
return card.has_danger || card_has_danger(card.next);
else
return false;
}
function card_has_warning(card) {
if (card)
return card.has_warning || card_has_warning(card.next);
else
return false;
}
function card_has_job(card) {
if (card)
return client.path_jobs[card.job_path] || card_has_job(card.next);
else
return false;
}
let info = null;
if (card_has_job(page.card))
info = <>{"\n"}<Spinner isInline size="md" /></>;
if (card_has_danger(page.card))
info = <>{"\n"}<ExclamationCircleIcon className="ct-icon-times-circle" />{info}</>;
else if (card_has_warning(page.card))
info = <>{"\n"}<ExclamationTriangleIcon className="ct-icon-exclamation-triangle" />{info}</>;
const icon = (show_icons && page.icon) ? <page.icon /> : null;
const name = crossref ? page.name : page_display_name(page);
const type = crossref ? page_block_summary(page) : page_type(page);
const location = crossref ? crossref.extra : page.columns[1];
let size = crossref ? crossref.size : page.columns[2];
const actions = crossref ? make_actions_kebab(crossref.actions) : make_page_kebab(page);
if (typeof size === "number") {
if (narrow)
size = <span className="usage-text">{fmt_size(size)}</span>;
else
size = <StorageSize size={size} />;
}
function find_button(element, parent_node_name, field) {
let column = 0;
while (element && element.nodeName !== parent_node_name) {
if (element.nodeName === "TD") {
let td = element;
while (td) {
column++;
td = td.previousElementSibling;
}
}
element = element.parentElement;
}
if (!element)
return null;
const selector = column > 0 ? `td:nth-child(${column}) button` : "button";
while ((element = element[field])) {
const button = element.querySelector(selector);
if (button)
return button;
}
return null;
}
function onRowKeyDown(event) {
const { code, target } = event;
function step(parent_node_name, field) {
find_button(target, parent_node_name, field)?.focus();
event.preventDefault();
}
if (code === "ArrowDown")
step("TR", "nextElementSibling");
else if (code === "ArrowUp")
step("TR", "previousElementSibling");
else if (code === "ArrowRight")
step("TD", "nextElementSibling");
else if (code === "ArrowLeft")
step("TD", "previousElementSibling");
}
function location_link(id, location) {
if (!location)
return null;
if (typeof location == "string")
return location;
if (!location.to)
return location.label;
return (
<Button ouiaId={id} isInline variant="link" onClick={() => cockpit.location.go(location.to)}>
<Truncate content={location.label} />
</Button>
);
}
const is_new = firstKeys.current !== false && !firstKeys.current.has(key);
row_keys.add(key);
if (narrow) {
rows.push(
<Card isPlain key={key}
className={"ct-small-table-card" +
(is_new ? " ct-new-item" : "")}
data-test-row-name={page.name}
data-test-row-location={location?.label || location}>
<CardBody>
<Split hasGutter>
{ icon && <SplitItem>{icon}</SplitItem> }
<SplitItem isFilled>
{location_link("id-link", { to: page.location, label: <strong>{name}</strong> })}
{info}
</SplitItem>
<SplitItem>{actions}</SplitItem>
</Split>
<Split hasGutter isWrappable>
<SplitItem>{type}</SplitItem>
<SplitItem isFilled>{location_link("location-link", location)}</SplitItem>
<SplitItem isFilled className="pf-v6-u-text-align-end">{size}</SplitItem>
</Split>
</CardBody>
</Card>);
} else {
const cols = [
<Td key="1">
<div className="indent" style={ { "--level": level } }>
{location_link("id-link", { to: page.location, label: name })}
{info}
</div>
</Td>,
<Td key="2" modifier="nowrap">{type}</Td>,
<Td key="3" modifier="nowrap">{location_link("location-link", location)}</Td>,
<Td key="4" className="storage-size-column">{size}</Td>,
<Td key="5" className="pf-v6-c-table__action">{actions || <div /> }</Td>,
];
if (show_icons)
cols.unshift(<Td key="0" className="storage-device-icon">{icon}</Td>);
rows.push(
<Tr key={key}
className={(border ? "" : " remove-border") +
(is_new ? " ct-new-item" : "")}
onKeyDown={onRowKeyDown}
data-test-row-name={page.name} data-test-row-location={location?.label || location}
>
{cols}
</Tr>);
}
}
function page_compare(a, b) {
if (a.category < b.category)
return -1;
else if (a.category > b.category)
return 1;
else
return a.name.localeCompare(b.name);
}
function sort(things, accessor, sorted) {
if (sorted === false)
return things;
return things.slice().sort((a, b) => page_compare(accessor(a), accessor(b)));
}
function make_page_rows(pages, level, last_has_border, key, sorted) {
const sorted_pages = sort(pages, p => p, sorted);
for (const p of sorted_pages) {
const is_last = (level === 0 || p === sorted_pages[pages.length - 1]);
const p_key = key + ":" + (p.key || p.name);
make_row(p, null, level, is_last && p.children.length === 0 && last_has_border, p_key);
make_page_rows(p.children, level + 1, is_last && last_has_border, p_key, p.options.sorted);
}
}
function make_crossref_rows(crossrefs) {
for (const c of sort(crossrefs, c => c.card.page, sorted))
make_row(c.card.page, c, 0, true, c.card.page.name);
}
if (pages)
make_page_rows(pages, 0, true, "", sorted);
else if (crossrefs)
make_crossref_rows(crossrefs);
if (firstKeys.current === false)
firstKeys.current = row_keys;
else {
firstKeys.current.forEach(v => {
if (!row_keys.has(v))
firstKeys.current.delete(v);
});
}
if (rows.length === 0) {
return <EmptyState>
<EmptyStateBody>
{emptyCaption}
</EmptyStateBody>
</EmptyState>;
}
let show_all_button = null;
if (rows.length > 50 && collapsed) {
show_all_button = (
<Bullseye>
<Button variant='link'
onKeyDown={ev => ev.key === "Enter" && setCollapsed(false)}
onClick={() => setCollapsed(false)}>
{cockpit.format(_("Show all $0 rows"), rows.length)}
</Button>
</Bullseye>);
rows = rows.slice(0, 50);
}
return (
<>
{ narrow
? rows
: <Table aria-label={aria_label}
className="page-table"
variant="compact">
{ pages &&
<Thead>
<Tr>
{ show_icons && <Th aria-label={_("Category")} />}
<Th>{_("ID")}</Th>
<Th>{_("Type")}</Th>
<Th>{_("Location")}</Th>
<Th className="storage-size-column-header">{_("Size")}</Th>
<Th aria-label={_("Actions")} />
</Tr>
</Thead>
}
<Tbody>
{rows}
</Tbody>
</Table>
}
{show_all_button}
</>);
};
export const ChildrenTable = ({ emptyCaption, aria_label, page, show_icons }) => {
return <PageTable emptyCaption={emptyCaption}
aria_label={aria_label}
pages={page.children}
sorted={page.options.sorted}
show_icons={show_icons} />;
};
function page_id_extra(page) {
let extra = "";
let c = page.card;
while (c) {
if (c.id_extra)
extra += " " + c.id_extra;
c = c.next;
}
return extra;
}
function page_display_name(page) {
let name = page.name;
const extra = page_id_extra(page);
if (extra)
name = name + " - " + extra;
return name;
}
const PageCardStackItems = ({ page, plot_state }) => {
const items = [];
let c = page.card;
while (c) {
items.push(<React.Fragment key={items.length}>
<StackItem>
<c.component card={c} plot_state={plot_state} {...c.props} />
</StackItem>
</React.Fragment>);
c = c.next;
}
return items.reverse();
};
export function block_location(block) {
return decode_filename(block.PreferredDevice).replace(/^\/dev\//, "");
}
const StorageBreadcrumb = ({ page }) => {
const parent_crumbs = [];
let pp = page.parent;
while (pp) {
if (pp.location) {
parent_crumbs.unshift(
<BreadcrumbItem key={pp.name} to={"#" + cockpit.location.encode(pp.location)}>
{page_display_name(pp)}
</BreadcrumbItem>
);
}
pp = pp.parent;
}
return (
<Breadcrumb>
{ parent_crumbs }
<BreadcrumbItem isActive>{page_display_name(page)}</BreadcrumbItem>
</Breadcrumb>);
};
export const StorageCard = ({ card, alert, alerts, actions, children }) => {
return (
<Card isPlain={card.page_location && card.page_location.length === 0} data-test-card-title={card.title}>
{ (client.in_anaconda_mode() && card.page.parent && !card.next) &&
<CardBody>
<StorageBreadcrumb page={card.page} />
</CardBody>
}
<CardHeader actions={{ actions: actions || <Actions actions={card.actions} /> }}>
<CardTitle>{card.title}</CardTitle>
</CardHeader>
{(alert || (alerts && alerts.length > 0)) && <CardBody><AlertGroup>{alert}{alerts}</AlertGroup></CardBody>}
{children}
<JobsPanel client={client} path={card.job_path} />
</Card>);
};
export const StorageDescription = ({ title, value, action, help, children }) => {
if (!value && !action && !children)
return null;
let content;
if (action && value) {
content = (
<Flex>
<FlexItem data-test-value>{value}</FlexItem>
<FlexItem data-test-action>{action}</FlexItem>
</Flex>);
} else {
content = value || action;
}
let help_popover;
if (help) {
help_popover = (
<Popover headerContent={help}>
<Button component="span" isInline variant="link" aria-label={_("more info")}>
<Icon status="info">
<HelpIcon />
</Icon>
</Button>
</Popover>
);
}
return (
<DescriptionListGroup data-test-desc-title={title}>
<DescriptionListTerm>{title} {help_popover}</DescriptionListTerm>
<DescriptionListDescription data-test-value={!(action && value)}>
{content}{children}
</DescriptionListDescription>
</DescriptionListGroup>);
};
export const StoragePage = ({ location, plot_state }) => {
const page = get_page_from_location(location);
return (
<Page id="storage" className={"pf-m-no-sidebar" + (client.in_anaconda_mode() ? " anaconda" : "")}>
{ (!client.in_anaconda_mode() && page.parent) &&
<PageBreadcrumb hasBodyWrapper={false} stickyOnBreakpoint={{ default: "top" }}>
<StorageBreadcrumb page={page} />
</PageBreadcrumb>
}
<PageSection hasBodyWrapper={false} isFilled={false}>
<Stack hasGutter>
<MultipathAlert client={client} />
<PageCardStackItems page={page} plot_state={plot_state} noarrow />
</Stack>
</PageSection>
</Page>
);
};