LCOV - code coverage report
Current view: top level - pkg/storaged - pages.jsx Coverage Total Hit
Test: cockpit Lines: 93.7 % 607 569
Test Date: 2026-07-17 12:03:54

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2023 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          113 : import cockpit from "cockpit";
       7          113 : import React, { useState, useRef } from "react";
       8              : import client from "./client";
       9              : import { useEvent } from "hooks.js";
      10              : 
      11              : import { AlertGroup } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
      12              : import { Card, CardHeader, CardTitle, CardBody } from "@patternfly/react-core/dist/esm/components/Card/index.js";
      13              : import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
      14              : import { DropdownGroup, DropdownList } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
      15              : import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
      16              : import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js";
      17              : import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye/index.js";
      18              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      19              : import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
      20              : import { EmptyState, EmptyStateBody } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
      21              : import { ExclamationTriangleIcon, ExclamationCircleIcon, HelpIcon } from "@patternfly/react-icons";
      22              : import { Page, PageBreadcrumb, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
      23              : import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
      24              : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
      25              : import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
      26              : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
      27              : import { Icon } from "@patternfly/react-core/dist/esm/components/Icon";
      28              : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover";
      29              : 
      30              : import {
      31              :     decode_filename, block_short_name, fmt_size,
      32              :     reset_available_spaces
      33              : } from "./utils.js";
      34              : import { StorageButton, StorageBarMenu, StorageMenuItem, StorageSize } from "./storage-controls.jsx";
      35              : import { MultipathAlert } from "./multipath.jsx";
      36              : import { JobsPanel } from "./jobs-panel.jsx";
      37              : import { Truncate } from "../lib/cockpit-components-truncate.jsx";
      38              : 
      39          113 : const _ = cockpit.gettext;
      40              : 
      41              : /* PAGES and CARDS
      42              : 
      43              :    This is the central hub of the Storage application. It implements a
      44              :    couple of concepts: "page", "card", "crossref", and "action". It
      45              :    also implements the React components that construct the actual UI
      46              :    out of them.
      47              : 
      48              :    On every change of the client, a tree of pages is constructed. Each
      49              :    page has a list of cards on it. The cards each have a list of
      50              :    actions. In addition to that, there are a few crossrefs (cross
      51              :    references) between pages that don't follow the tree structure.
      52              : 
      53              :    The tree of pages starts with "make_overview_page" in
      54              :    overview/overview.jsx. That function creates the data structures
      55              :    that represent the overview page (using functions exported from
      56              :    this file), and is also responsible for kicking off the creation of
      57              :    its child pages. (And each page construction function of course
      58              :    also creates the cards and actions for that page).
      59              : 
      60              :    Pages can be shown by themselves, or be part of various tables in
      61              :    other pages. For example, each row in the table in the overview
      62              :    page represents another page, and clicking on it will navigate to
      63              :    that page. (In fact, the overview table shows the whole tree. Other
      64              :    pages show subsets of the tree in their tables.)
      65              : 
      66              :    Most of the complications with pages and cards are there to support
      67              :    putting pages into tables. Without having to do that, we wouldn't
      68              :    need the pages and cards abstractions at all.
      69              : 
      70              :    A page derives its "ID", "Type", "Location", and "Size" properties
      71              :    from its cards in various ad-hoc and arguably too complex
      72              :    ways. This is seems necessary since the cards that end up together
      73              :    on a page can vary significantly depending on the actual storage
      74              :    configuration of the machine.
      75              : 
      76              :    For example, a MDRAID device might have a encrypted LVM2 physical
      77              :    volume directly on it, or it might have a partition table with a
      78              :    PV, in which case the card for the PV appears on another page
      79              :    together with a card for the partition.
      80              : 
      81              :    Cards are constructed with a call like this:
      82              : 
      83              :        const card = new_card({
      84              :            next: next_card,
      85              :            title: ...,
      86              :            component: CardComponent,
      87              :            props: { react_props... }
      88              :        });
      89              : 
      90              :    A page is constructed with a call like this:
      91              : 
      92              :        new_page(parent_page, first_card, { options... });
      93              : 
      94              :    A call to "new_page" will implicitly build the tree by inserting
      95              :    the new_page into the children of "parent_page".
      96              : 
      97              :    The main React component for this application is StoragePage. It
      98              :    will find the correct page for the browser URL and display it,
      99              :    together with all the trimmings like the breadcrumb trail.
     100              : 
     101              :    The page will be filled with the React components specified by its
     102              :    cards. Such a component should use StorageCard to get the standard
     103              :    look of all cards, and to work well with the test helpers.
     104              : 
     105              :    Tables of pages are handled by the PageTable component exported
     106              :    from this file or, more likely, its specialized version
     107              :    ChildrenTable.
     108              : 
     109              :    A ChildrenTable shows all direct and indirect children of a given
     110              :    page, by using a PageTable appropriately. A raw PageTable can also
     111              :    show cross references.
     112              : 
     113              :    A cross reference associates a page with an arbitrary key. For
     114              :    example, a page that represents a LVM2 physical volume will
     115              :    associate itself with the DBusProxy for its volume group. The page
     116              :    for the volume group will then retrieve all pages associated with
     117              :    its DBusProxy and put them into a table to show all its physical
     118              :    volumes.
     119              : 
     120              :    When a page appears in such a crossref table, it has dedicated
     121              :    actions, a dedicated size, and generally looks different from when
     122              :    it appears in a "normal" children's table. This is another source of
     123              :    complication of how cards and pages interact.
     124              : */
     125              : 
     126          113 : let pages = null;
     127          113 : let crossrefs = null;
     128              : 
     129          113 : export const PAGE_CATEGORY_PHYSICAL = 1;
     130          113 : export const PAGE_CATEGORY_VIRTUAL = 2;
     131          113 : export const PAGE_CATEGORY_NETWORK = 3;
     132              : 
     133          112 : export function reset_pages() {
     134          112 :     pages = new Map();
     135          112 :     crossrefs = new Map();
     136          112 :     reset_available_spaces();
     137          112 : }
     138              : 
     139          113 : function name_from_card(card) {
     140          113 :     if (!card)
     141          113 :         return null;
     142          113 :     return name_from_card(card.next) || card.page_name;
     143          113 : }
     144              : 
     145          113 : function icon_from_card(card) {
     146          113 :     if (!card)
     147          113 :         return null;
     148          113 :     return icon_from_card(card.next) || card.page_icon;
     149          113 : }
     150              : 
     151          113 : function category_from_card(card) {
     152          113 :     if (!card)
     153          113 :         return null;
     154          113 :     return category_from_card(card.next) || card.page_category;
     155          113 : }
     156              : 
     157          113 : function key_from_card(card) {
     158          113 :     if (!card)
     159          113 :         return null;
     160          113 :     return key_from_card(card.next) || card.page_key;
     161          113 : }
     162              : 
     163          113 : function location_from_card(card) {
     164          113 :     if (!card)
     165          113 :         return null;
     166          113 :     return location_from_card(card.next) || card.page_location;
     167          113 : }
     168              : 
     169          113 : function size_from_card(card) {
     170          113 :     if (!card)
     171          113 :         return null;
     172          113 :     if (card.page_size)
     173          113 :         return card.page_size;
     174          113 :     return size_from_card(card.next);
     175          113 : }
     176              : 
     177          113 : export function new_page(parent, card, options) {
     178          113 :     const page = {
     179          113 :         location: location_from_card(card),
     180          113 :         name: name_from_card(card),
     181          113 :         icon: icon_from_card(card),
     182          113 :         category: category_from_card(card),
     183          113 :         key: key_from_card(card),
     184          113 :         parent,
     185          113 :         children: [],
     186          113 :         card,
     187          113 :         options: options || {},
     188          113 :     };
     189          113 :     page.columns = [
     190          113 :         card.title,
     191          113 :         card.location,
     192          113 :         size_from_card(card),
     193          113 :     ];
     194          113 :     if (parent)
     195          113 :         parent.children.push(page);
     196          113 :     while (card) {
     197          113 :         card.page = page;
     198          113 :         card = card.next;
     199          113 :     }
     200          113 :     if (page.location) {
     201          113 :         pages.set(JSON.stringify(page.location), page);
     202          113 :         if (page.location.length == 0) {
     203              :             // This is the Overview page. Make it the parent of the
     204              :             // special "not found" page (but don't make the "not
     205              :             // found" page a child of the Overview...)
     206          113 :             not_found_page.parent = page;
     207          113 :         }
     208          113 :     }
     209          113 :     return page;
     210          113 : }
     211              : 
     212          113 : export function new_card({
     213          113 :     title, location, next,
     214          113 :     type_extra, id_extra,
     215          113 :     page_name, page_icon, page_category, page_key, page_location, page_size,
     216          113 :     page_block,
     217          113 :     for_summary,
     218          113 :     has_warning, has_danger, job_path,
     219          113 :     component, props,
     220          113 :     actions,
     221          113 : }) {
     222          113 :     if (page_block) {
     223          113 :         page_location = [block_location(page_block)];
     224          113 :         page_name = block_short_name(page_block);
     225          113 :         page_size = page_block.Size;
     226          113 :         job_path = page_block.path;
     227          113 :     }
     228          113 :     return {
     229          113 :         title,
     230          113 :         location,
     231          113 :         next,
     232          113 :         type_extra,
     233          113 :         page_name,
     234          113 :         page_icon,
     235          113 :         page_category: page_category || PAGE_CATEGORY_PHYSICAL,
     236          113 :         page_key,
     237          113 :         page_location,
     238          113 :         page_size,
     239          113 :         for_summary,
     240          113 :         component,
     241          113 :         props,
     242          113 :         has_warning,
     243          113 :         has_danger,
     244          113 :         job_path,
     245          112 :         actions: actions ? actions.filter(a => !!a) : null,
     246          113 :         id_extra,
     247          113 :     };
     248          113 : }
     249              : 
     250           54 : export function register_crossref(crossref) {
     251           54 :     const val = crossrefs.get(crossref.key) || [];
     252            1 :     crossref.actions = crossref.actions ? crossref.actions.filter(a => !!a) : null;
     253           54 :     val.push(crossref);
     254           54 :     crossrefs.set(crossref.key, val);
     255           54 : }
     256              : 
     257           50 : export function get_crossrefs(key) {
     258           50 :     return crossrefs.get(key);
     259           50 : }
     260              : 
     261              : /* Getting the page for a navigation location.
     262              :  *
     263              :  * We have a special "not found" page that is returned when there is
     264              :  * no real page at the given location.
     265              :  */
     266              : 
     267           10 : const NotFoundCard = ({ card }) => {
     268           10 :     return (
     269           10 :         <StorageCard card={card}>
     270           10 :             <CardBody>
     271           10 :                 {_("Not found")}
     272           10 :             </CardBody>
     273           10 :         </StorageCard>);
     274           10 : };
     275              : 
     276          113 : const not_found_page = new_page(null, new_card({ page_name: _("Not found"), component: NotFoundCard }));
     277              : 
     278          112 : export function get_page_from_location(location) {
     279           22 :     return pages?.get(JSON.stringify(location)) || not_found_page;
     280          112 : }
     281              : 
     282              : /* Common UI things
     283              :  */
     284              : 
     285           25 : export function navigate_away_from_card(card) {
     286           25 :     if (!card)
     287           25 :         return;
     288              : 
     289           25 :     const loc = cockpit.location;
     290           25 :     const page = card.page;
     291           25 :     if (page.parent && JSON.stringify(loc.path) == JSON.stringify(page.location))
     292           19 :         loc.go(page.parent.location);
     293           25 : }
     294              : 
     295            4 : export function navigate_to_new_card_location(card, location) {
     296            4 :     if (!card)
     297            4 :         return;
     298              : 
     299            4 :     const loc = cockpit.location;
     300            4 :     const page = card.page;
     301            4 :     if (JSON.stringify(loc.path) == JSON.stringify(page.location))
     302            4 :         loc.go(location);
     303            4 : }
     304              : 
     305          112 : function make_menu_item(action) {
     306           69 :     return <StorageMenuItem key={action.title} onClick={() => action.action(true)}
     307          112 :                             danger={action.danger} excuse={action.excuse}>
     308          112 :         {action.title}
     309          112 :     </StorageMenuItem>;
     310          112 : }
     311              : 
     312          108 : function make_page_kebab(page) {
     313          108 :     const items = [];
     314              : 
     315          108 :     function card_item_group(card) {
     316          104 :         const a = card.actions || [];
     317          108 :         if (a.length > 0) {
     318          108 :             let result = <DropdownList key={items.length}>{a.map(make_menu_item)}</DropdownList>;
     319          108 :             if (card.title) {
     320          108 :                 result = <DropdownGroup key={items.length} label={card.title} className="storage-menu-title">
     321          108 :                     {result}
     322          108 :                 </DropdownGroup>;
     323          108 :             }
     324          108 :             return result;
     325          108 :         } else
     326          105 :             return null;
     327          108 :     }
     328              : 
     329          108 :     let c = page.card;
     330          108 :     while (c) {
     331          108 :         const g = card_item_group(c);
     332          108 :         if (g) {
     333          108 :             if (items.length > 0)
     334          108 :                 items.push(<Divider key={"div" + items.length} />);
     335          108 :             items.push(g);
     336          108 :         }
     337          108 :         c = c.next;
     338          108 :     }
     339              : 
     340          108 :     if (items.length == 0)
     341            9 :         return null;
     342              : 
     343          108 :     return <StorageBarMenu menuItems={items} isKebab />;
     344          108 : }
     345              : 
     346           46 : function make_actions_kebab(actions) {
     347           44 :     if (!actions || actions.length == 0)
     348           21 :         return null;
     349              : 
     350           31 :     return <StorageBarMenu menuItems={actions.map(make_menu_item)} isKebab />;
     351           46 : }
     352              : 
     353           93 : export const Actions = ({ actions, onlyMenu }) => {
     354           93 :     const narrow = useIsNarrow();
     355              : 
     356           93 :     function for_menu(action) {
     357              :         // Determine whether a action should get a button or be in the
     358              :         // menu
     359              : 
     360              :         // When requested or when in a narrow layout, put everything into the menu
     361           93 :         if (onlyMenu || narrow)
     362           10 :             return true;
     363              : 
     364              :         // Everything that is dangerous goes to the menu
     365           93 :         if (action.danger)
     366           93 :             return true;
     367              : 
     368           82 :         return false;
     369           93 :     }
     370              : 
     371           93 :     const buttons = [];
     372           93 :     const items = [];
     373              : 
     374           93 :     if (!actions)
     375           47 :         return null;
     376              : 
     377           93 :     for (const action of actions) {
     378           93 :         if (for_menu(action))
     379           82 :             items.push(make_menu_item(action));
     380              :         else
     381           82 :             buttons.push(
     382           43 :                 <StorageButton key={action.title} onClick={() => action.action(false)}
     383            9 :                                kind={action.danger ? "danger" : null} excuse={action.excuse}
     384           82 :                                spinner={action.spinner}>
     385           82 :                     {action.title}
     386           82 :                 </StorageButton>);
     387           93 :     }
     388              : 
     389           93 :     if (items.length > 0)
     390           93 :         buttons.push(<StorageBarMenu key="menu" menuItems={items} isKebab />);
     391              : 
     392           93 :     return buttons;
     393           93 : };
     394              : 
     395          108 : function page_type_extra(page) {
     396          108 :     const extra = [];
     397          108 :     let c = page.card;
     398          108 :     while (c) {
     399          108 :         if (c.type_extra)
     400          103 :             extra.push(c.type_extra);
     401          108 :         c = c.next;
     402          108 :     }
     403          108 :     return extra;
     404          108 : }
     405              : 
     406          108 : function page_type(page) {
     407          108 :     const type = page.card.title;
     408          108 :     const extra = page_type_extra(page);
     409          108 :     if (extra.length > 0)
     410          103 :         return type + " (" + extra.join(", ") + ")";
     411              :     else
     412          108 :         return type;
     413          108 : }
     414              : 
     415              : // PAGE_BLOCK_SUMMARY
     416              : //
     417              : // Describe a page in a way that is useful to identify it when
     418              : // deciding which block device to format, or which block devices to
     419              : // make a volume group out of, for example. The block device itself
     420              : // (such as /dev/sda5) should not be part of the description; it is in
     421              : // another table column already.
     422              : //
     423              : // The first card on the page that has the "for_summary" flag set
     424              : // provides the description, and the type extra of all cards
     425              : // leading to it are added.  The description is either the title
     426              : // of the card, or its id_extra.
     427              : //
     428              : // For more context, the description for the parent of a page is also
     429              : // added.
     430              : //
     431              : // Thus, we end up with things like "Partition - MDRAID device".
     432              : 
     433           46 : function page_block_summary_1(page) {
     434           46 :     let description = null;
     435           46 :     const extra = [];
     436           46 :     for (let card = page.card; card; card = card.next) {
     437           45 :         if (card.for_summary) {
     438           29 :             description = card.id_extra || card.title;
     439           45 :             break;
     440           45 :         }
     441           46 :         if (card.type_extra)
     442            6 :             extra.push(card.type_extra);
     443           46 :     }
     444              : 
     445           45 :     if (description && extra.length > 0)
     446            6 :         description += " (" + extra.join(", ") + ")";
     447              : 
     448           46 :     return description;
     449           46 : }
     450              : 
     451           46 : function page_block_summary(page) {
     452           46 :     const desc1 = page_block_summary_1(page);
     453           10 :     const desc2 = page.parent && page.parent.parent && page_block_summary_1(page.parent);
     454           45 :     if (desc1 && desc2)
     455            6 :         return desc1 + " - " + desc2;
     456              :     else
     457            7 :         return desc1 || desc2;
     458           46 : }
     459              : 
     460          113 : let narrow_query = null;
     461              : 
     462          112 : export const useIsNarrow = (onChange) => {
     463          112 :     if (!narrow_query) {
     464          112 :         const val = window.getComputedStyle(window.document.body).getPropertyValue("--pf-t--global--breakpoint--md");
     465          112 :         narrow_query = window.matchMedia(`(max-width: ${val})`);
     466          112 :     }
     467          112 :     useEvent(narrow_query, "change", onChange);
     468              : 
     469          112 :     return narrow_query.matches;
     470          112 : };
     471              : 
     472          110 : export const PageTable = ({ emptyCaption, aria_label, pages, crossrefs, sorted, show_icons }) => {
     473          110 :     const [collapsed, setCollapsed] = useState(true);
     474          110 :     const firstKeys = useRef(false);
     475            2 :     const narrow = useIsNarrow(() => { firstKeys.current = false });
     476              : 
     477          110 :     let rows = [];
     478          110 :     const row_keys = new Set();
     479              : 
     480          110 :     function make_row(page, crossref, level, border, key) {
     481          110 :         function card_has_danger(card) {
     482          110 :             if (card)
     483          110 :                 return card.has_danger || card_has_danger(card.next);
     484              :             else
     485          110 :                 return false;
     486          110 :         }
     487              : 
     488          110 :         function card_has_warning(card) {
     489          110 :             if (card)
     490          110 :                 return card.has_warning || card_has_warning(card.next);
     491              :             else
     492          110 :                 return false;
     493          110 :         }
     494              : 
     495          110 :         function card_has_job(card) {
     496          110 :             if (card)
     497          110 :                 return client.path_jobs[card.job_path] || card_has_job(card.next);
     498              :             else
     499          110 :                 return false;
     500          110 :         }
     501              : 
     502          110 :         let info = null;
     503          110 :         if (card_has_job(page.card))
     504           12 :             info = <>{"\n"}<Spinner isInline size="md" /></>;
     505          110 :         if (card_has_danger(page.card))
     506           14 :             info = <>{"\n"}<ExclamationCircleIcon className="ct-icon-times-circle" />{info}</>;
     507          110 :         else if (card_has_warning(page.card))
     508           26 :             info = <>{"\n"}<ExclamationTriangleIcon className="ct-icon-exclamation-triangle" />{info}</>;
     509              : 
     510          107 :         const icon = (show_icons && page.icon) ? <page.icon /> : null;
     511           50 :         const name = crossref ? page.name : page_display_name(page);
     512           50 :         const type = crossref ? page_block_summary(page) : page_type(page);
     513           50 :         const location = crossref ? crossref.extra : page.columns[1];
     514           50 :         let size = crossref ? crossref.size : page.columns[2];
     515           50 :         const actions = crossref ? make_actions_kebab(crossref.actions) : make_page_kebab(page);
     516              : 
     517          110 :         if (typeof size === "number") {
     518          110 :             if (narrow)
     519           11 :                 size = <span className="usage-text">{fmt_size(size)}</span>;
     520              :             else
     521          110 :                 size = <StorageSize size={size} />;
     522          110 :         }
     523              : 
     524            0 :         function find_button(element, parent_node_name, field) {
     525            0 :             let column = 0;
     526            0 :             while (element && element.nodeName != parent_node_name) {
     527            0 :                 if (element.nodeName == "TD") {
     528            0 :                     let td = element;
     529            0 :                     while (td) {
     530            0 :                         column++;
     531            0 :                         td = td.previousElementSibling;
     532            0 :                     }
     533            0 :                 }
     534            0 :                 element = element.parentElement;
     535            0 :             }
     536            0 :             if (!element)
     537            0 :                 return null;
     538            0 :             const selector = column > 0 ? `td:nth-child(${column}) button` : "button";
     539            0 :             while ((element = element[field])) {
     540            0 :                 const button = element.querySelector(selector);
     541            0 :                 if (button)
     542            0 :                     return button;
     543            0 :             }
     544            0 :             return null;
     545            0 :         }
     546              : 
     547            0 :         function onRowKeyDown(event) {
     548            0 :             const { code, target } = event;
     549              : 
     550            0 :             function step(parent_node_name, field) {
     551            0 :                 find_button(target, parent_node_name, field)?.focus();
     552            0 :                 event.preventDefault();
     553            0 :             }
     554              : 
     555            0 :             if (code == "ArrowDown")
     556            0 :                 step("TR", "nextElementSibling");
     557            0 :             else if (code == "ArrowUp")
     558            0 :                 step("TR", "previousElementSibling");
     559            0 :             else if (code == "ArrowRight")
     560            0 :                 step("TD", "nextElementSibling");
     561            0 :             else if (code == "ArrowLeft")
     562            0 :                 step("TD", "previousElementSibling");
     563            0 :         }
     564              : 
     565          110 :         function location_link(id, location) {
     566          110 :             if (!location)
     567          110 :                 return null;
     568          110 :             if (typeof location == "string")
     569          107 :                 return location;
     570          110 :             if (!location.to)
     571           53 :                 return location.label;
     572              : 
     573          110 :             return (
     574           85 :                 <Button ouiaId={id} isInline variant="link" onClick={() => cockpit.location.go(location.to)}>
     575          110 :                     <Truncate content={location.label} />
     576          110 :                 </Button>
     577              :             );
     578          110 :         }
     579              : 
     580           92 :         const is_new = firstKeys.current != false && !firstKeys.current.has(key);
     581          110 :         row_keys.add(key);
     582              : 
     583           11 :         if (narrow) {
     584           11 :             rows.push(
     585           11 :                 <Card isPlain key={key}
     586           11 :                       className={"ct-small-table-card" +
     587           11 :                                  (is_new ? " ct-new-item" : "")}
     588           11 :                       data-test-row-name={page.name}
     589           11 :                       data-test-row-location={location?.label || location}>
     590           11 :                     <CardBody>
     591           11 :                         <Split hasGutter>
     592           11 :                             { icon && <SplitItem>{icon}</SplitItem> }
     593           11 :                             <SplitItem isFilled>
     594           11 :                                 {location_link("id-link", { to: page.location, label: <strong>{name}</strong> })}
     595           11 :                                 {info}
     596           11 :                             </SplitItem>
     597           11 :                             <SplitItem>{actions}</SplitItem>
     598           11 :                         </Split>
     599           11 :                         <Split hasGutter isWrappable>
     600           11 :                             <SplitItem>{type}</SplitItem>
     601           11 :                             <SplitItem isFilled>{location_link("location-link", location)}</SplitItem>
     602           11 :                             <SplitItem isFilled className="pf-v6-u-text-align-end">{size}</SplitItem>
     603           11 :                         </Split>
     604           11 :                     </CardBody>
     605           11 :                 </Card>);
     606           11 :         } else {
     607          110 :             const cols = [
     608          110 :                 <Td key="1">
     609          110 :                     <div className="indent" style={ { "--level": level } }>
     610          110 :                         {location_link("id-link", { to: page.location, label: name })}
     611          110 :                         {info}
     612          110 :                     </div>
     613          110 :                 </Td>,
     614          110 :                 <Td key="2" modifier="nowrap">{type}</Td>,
     615          110 :                 <Td key="3" modifier="nowrap">{location_link("location-link", location)}</Td>,
     616          110 :                 <Td key="4" className="storage-size-column">{size}</Td>,
     617           25 :                 <Td key="5" className="pf-v6-c-table__action">{actions || <div /> }</Td>,
     618          110 :             ];
     619          110 :             if (show_icons)
     620          110 :                 cols.unshift(<Td key="0" className="storage-device-icon">{icon}</Td>);
     621              : 
     622          110 :             rows.push(
     623          110 :                 <Tr key={key}
     624          107 :                     className={(border ? "" : " remove-border") +
     625           81 :                                (is_new ? " ct-new-item" : "")}
     626          110 :                     onKeyDown={onRowKeyDown}
     627          107 :                     data-test-row-name={page.name} data-test-row-location={location?.label || location}
     628              :                 >
     629          110 :                     {cols}
     630          110 :                 </Tr>);
     631          110 :         }
     632          110 :     }
     633              : 
     634          101 :     function page_compare(a, b) {
     635          101 :         if (a.category < b.category)
     636           15 :             return -1;
     637          100 :         else if (a.category > b.category)
     638           53 :             return 1;
     639              :         else
     640           99 :             return a.name.localeCompare(b.name);
     641          101 :     }
     642              : 
     643          110 :     function sort(things, accessor, sorted) {
     644          110 :         if (sorted === false)
     645          103 :             return things;
     646              :         // HACK: Use toSorted() once enough users upgrade to at least Firefox ESR 115
     647              :         // See: https://github.com/cockpit-project/cockpit/issues/19848
     648          101 :         return things.slice().sort((a, b) => page_compare(accessor(a), accessor(b)));
     649          110 :     }
     650              : 
     651          108 :     function make_page_rows(pages, level, last_has_border, key, sorted) {
     652           99 :         const sorted_pages = sort(pages, p => p, sorted);
     653          108 :         for (const p of sorted_pages) {
     654          105 :             const is_last = (level == 0 || p == sorted_pages[pages.length - 1]);
     655          108 :             const p_key = key + ":" + (p.key || p.name);
     656          108 :             make_row(p, null, level, is_last && p.children.length == 0 && last_has_border, p_key);
     657          108 :             make_page_rows(p.children, level + 1, is_last && last_has_border, p_key, p.options.sorted);
     658          108 :         }
     659          108 :     }
     660              : 
     661           46 :     function make_crossref_rows(crossrefs) {
     662           21 :         for (const c of sort(crossrefs, c => c.card.page, sorted))
     663           46 :             make_row(c.card.page, c, 0, true, c.card.page.name);
     664           46 :     }
     665              : 
     666          110 :     if (pages)
     667           50 :         make_page_rows(pages, 0, true, "", sorted);
     668           50 :     else if (crossrefs)
     669           50 :         make_crossref_rows(crossrefs);
     670              : 
     671          110 :     if (firstKeys.current === false)
     672           92 :         firstKeys.current = row_keys;
     673           92 :     else {
     674           87 :         firstKeys.current.forEach(v => {
     675           87 :             if (!row_keys.has(v))
     676           25 :                 firstKeys.current.delete(v);
     677           87 :         });
     678           92 :     }
     679              : 
     680           39 :     if (rows.length == 0) {
     681           39 :         return <EmptyState>
     682           39 :             <EmptyStateBody>
     683           39 :                 {emptyCaption}
     684           39 :             </EmptyStateBody>
     685           39 :         </EmptyState>;
     686           39 :     }
     687              : 
     688          110 :     let show_all_button = null;
     689           12 :     if (rows.length > 50 && collapsed) {
     690           12 :         show_all_button = (
     691           12 :             <Bullseye>
     692           12 :                 <Button variant='link'
     693            0 :                         onKeyDown={ev => ev.key === "Enter" && setCollapsed(false)}
     694            1 :                         onClick={() => setCollapsed(false)}>
     695           12 :                     {cockpit.format(_("Show all $0 rows"), rows.length)}
     696           12 :                 </Button>
     697           12 :             </Bullseye>);
     698           12 :         rows = rows.slice(0, 50);
     699           12 :     }
     700              : 
     701          110 :     return (
     702          110 :         <>
     703          110 :             { narrow
     704           11 :                 ? rows
     705          110 :                 : <Table aria-label={aria_label}
     706          110 :                       className="page-table"
     707          110 :                       variant="compact">
     708          110 :                     { pages &&
     709          110 :                     <Thead>
     710          110 :                         <Tr>
     711          110 :                             { show_icons && <Th aria-label={_("Category")} />}
     712          110 :                             <Th>{_("ID")}</Th>
     713          110 :                             <Th>{_("Type")}</Th>
     714          110 :                             <Th>{_("Location")}</Th>
     715          110 :                             <Th className="storage-size-column-header">{_("Size")}</Th>
     716          110 :                             <Th aria-label={_("Actions")} />
     717          110 :                         </Tr>
     718          110 :                     </Thead>
     719              :                     }
     720          110 :                     <Tbody>
     721          110 :                         {rows}
     722          110 :                     </Tbody>
     723          110 :                 </Table>
     724              :             }
     725          110 :             {show_all_button}
     726          110 :         </>);
     727          110 : };
     728              : 
     729          108 : export const ChildrenTable = ({ emptyCaption, aria_label, page, show_icons }) => {
     730          108 :     return <PageTable emptyCaption={emptyCaption}
     731          108 :                       aria_label={aria_label}
     732          108 :                       pages={page.children}
     733          108 :                       sorted={page.options.sorted}
     734          108 :                       show_icons={show_icons} />;
     735          108 : };
     736              : 
     737          112 : function page_id_extra(page) {
     738          112 :     let extra = "";
     739          112 :     let c = page.card;
     740          112 :     while (c) {
     741          112 :         if (c.id_extra)
     742          109 :             extra += " " + c.id_extra;
     743          112 :         c = c.next;
     744          112 :     }
     745          112 :     return extra;
     746          112 : }
     747              : 
     748          112 : function page_display_name(page) {
     749          112 :     let name = page.name;
     750          112 :     const extra = page_id_extra(page);
     751          112 :     if (extra)
     752          109 :         name = name + " - " + extra;
     753          112 :     return name;
     754          112 : }
     755              : 
     756          112 : const PageCardStackItems = ({ page, plot_state }) => {
     757          112 :     const items = [];
     758          112 :     let c = page.card;
     759          112 :     while (c) {
     760          112 :         items.push(<React.Fragment key={items.length}>
     761          112 :             <StackItem>
     762          112 :                 <c.component card={c} plot_state={plot_state} {...c.props} />
     763          112 :             </StackItem>
     764          112 :         </React.Fragment>);
     765          112 :         c = c.next;
     766          112 :     }
     767              : 
     768          112 :     return items.reverse();
     769          112 : };
     770              : 
     771          112 : export function block_location(block) {
     772          112 :     return decode_filename(block.PreferredDevice).replace(/^\/dev\//, "");
     773          112 : }
     774              : 
     775           93 : const StorageBreadcrumb = ({ page }) => {
     776           93 :     const parent_crumbs = [];
     777           93 :     let pp = page.parent;
     778           93 :     while (pp) {
     779           93 :         if (pp.location) {
     780           93 :             parent_crumbs.unshift(
     781           93 :                 <BreadcrumbItem key={pp.name} to={"#" + cockpit.location.encode(pp.location)}>
     782           93 :                     {page_display_name(pp)}
     783           93 :                 </BreadcrumbItem>
     784           93 :             );
     785           93 :         }
     786           93 :         pp = pp.parent;
     787           93 :     }
     788              : 
     789           93 :     return (
     790           93 :         <Breadcrumb>
     791           93 :             { parent_crumbs }
     792           93 :             <BreadcrumbItem isActive>{page_display_name(page)}</BreadcrumbItem>
     793           93 :         </Breadcrumb>);
     794           93 : };
     795              : 
     796          112 : export const StorageCard = ({ card, alert, alerts, actions, children }) => {
     797          112 :     return (
     798          112 :         <Card isPlain={card.page_location && card.page_location.length == 0} data-test-card-title={card.title}>
     799           17 :             { (client.in_anaconda_mode() && card.page.parent && !card.next) &&
     800           17 :             <CardBody>
     801           17 :                 <StorageBreadcrumb page={card.page} />
     802           17 :             </CardBody>
     803              :             }
     804           95 :             <CardHeader actions={{ actions: actions || <Actions actions={card.actions} /> }}>
     805          112 :                 <CardTitle>{card.title}</CardTitle>
     806          112 :             </CardHeader>
     807           25 :             {(alert || (alerts && alerts.length > 0)) && <CardBody><AlertGroup>{alert}{alerts}</AlertGroup></CardBody>}
     808          112 :             {children}
     809          112 :             <JobsPanel client={client} path={card.job_path} />
     810          112 :         </Card>);
     811          112 : };
     812              : 
     813           93 : export const StorageDescription = ({ title, value, action, help, children }) => {
     814           85 :     if (!value && !action && !children)
     815           14 :         return null;
     816              : 
     817           93 :     let content;
     818           83 :     if (action && value) {
     819           83 :         content = (
     820           83 :             <Flex>
     821           83 :                 <FlexItem data-test-value>{value}</FlexItem>
     822           83 :                 <FlexItem data-test-action>{action}</FlexItem>
     823           83 :             </Flex>);
     824           83 :     } else {
     825           85 :         content = value || action;
     826           93 :     }
     827              : 
     828           93 :     let help_popover;
     829           10 :     if (help) {
     830           10 :         help_popover = (
     831           10 :             <Popover headerContent={help}>
     832           10 :                 <Button component="span" isInline variant="link" aria-label={_("more info")}>
     833           10 :                     <Icon status="info">
     834           10 :                         <HelpIcon />
     835           10 :                     </Icon>
     836           10 :                 </Button>
     837           10 :             </Popover>
     838              :         );
     839           10 :     }
     840              : 
     841           93 :     return (
     842           93 :         <DescriptionListGroup data-test-desc-title={title}>
     843           93 :             <DescriptionListTerm>{title} {help_popover}</DescriptionListTerm>
     844           83 :             <DescriptionListDescription data-test-value={!(action && value)}>
     845           93 :                 {content}{children}
     846           93 :             </DescriptionListDescription>
     847           93 :         </DescriptionListGroup>);
     848           93 : };
     849              : 
     850          112 : export const StoragePage = ({ location, plot_state }) => {
     851          112 :     const page = get_page_from_location(location);
     852              : 
     853          112 :     return (
     854           13 :         <Page id="storage" className={"pf-m-no-sidebar" + (client.in_anaconda_mode() ? " anaconda" : "")}>
     855          102 :             { (!client.in_anaconda_mode() && page.parent) &&
     856           91 :             <PageBreadcrumb hasBodyWrapper={false} stickyOnBreakpoint={{ default: "top" }}>
     857           91 :                 <StorageBreadcrumb page={page} />
     858           91 :             </PageBreadcrumb>
     859              :             }
     860          112 :             <PageSection hasBodyWrapper={false} isFilled={false}>
     861          112 :                 <Stack hasGutter>
     862          112 :                     <MultipathAlert client={client} />
     863          112 :                     <PageCardStackItems page={page} plot_state={plot_state} noarrow />
     864          112 :                 </Stack>
     865          112 :             </PageSection>
     866          112 :         </Page>
     867              :     );
     868          112 : };
        

Generated by: LCOV version 2.0-1