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 11 : const NotFoundCard = ({ card }) => {
268 11 : return (
269 11 : <StorageCard card={card}>
270 11 : <CardBody>
271 11 : {_("Not found")}
272 11 : </CardBody>
273 11 : </StorageCard>);
274 11 : };
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 23 : export function navigate_away_from_card(card) {
286 23 : if (!card)
287 23 : return;
288 :
289 23 : const loc = cockpit.location;
290 23 : const page = card.page;
291 23 : if (page.parent && JSON.stringify(loc.path) == JSON.stringify(page.location))
292 17 : loc.go(page.parent.location);
293 23 : }
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 67 : 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 106 : function make_page_kebab(page) {
313 106 : const items = [];
314 :
315 106 : function card_item_group(card) {
316 102 : const a = card.actions || [];
317 106 : if (a.length > 0) {
318 106 : let result = <DropdownList key={items.length}>{a.map(make_menu_item)}</DropdownList>;
319 106 : if (card.title) {
320 106 : result = <DropdownGroup key={items.length} label={card.title} className="storage-menu-title">
321 106 : {result}
322 106 : </DropdownGroup>;
323 106 : }
324 106 : return result;
325 106 : } else
326 103 : return null;
327 106 : }
328 :
329 106 : let c = page.card;
330 106 : while (c) {
331 106 : const g = card_item_group(c);
332 106 : if (g) {
333 106 : if (items.length > 0)
334 106 : items.push(<Divider key={"div" + items.length} />);
335 106 : items.push(g);
336 106 : }
337 106 : c = c.next;
338 106 : }
339 :
340 106 : if (items.length == 0)
341 6 : return null;
342 :
343 106 : return <StorageBarMenu menuItems={items} isKebab />;
344 106 : }
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 12 : 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 48 : 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 45 : <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 106 : function page_type_extra(page) {
396 106 : const extra = [];
397 106 : let c = page.card;
398 106 : while (c) {
399 106 : if (c.type_extra)
400 101 : extra.push(c.type_extra);
401 106 : c = c.next;
402 106 : }
403 106 : return extra;
404 106 : }
405 :
406 106 : function page_type(page) {
407 106 : const type = page.card.title;
408 106 : const extra = page_type_extra(page);
409 106 : if (extra.length > 0)
410 101 : return type + " (" + extra.join(", ") + ")";
411 : else
412 106 : return type;
413 106 : }
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 6 : 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 13 : info = <>{"\n"}<ExclamationCircleIcon className="ct-icon-times-circle" />{info}</>;
507 110 : else if (card_has_warning(page.card))
508 27 : info = <>{"\n"}<ExclamationTriangleIcon className="ct-icon-exclamation-triangle" />{info}</>;
509 :
510 107 : const icon = (show_icons && page.icon) ? <page.icon /> : null;
511 49 : const name = crossref ? page.name : page_display_name(page);
512 49 : const type = crossref ? page_block_summary(page) : page_type(page);
513 49 : const location = crossref ? crossref.extra : page.columns[1];
514 49 : let size = crossref ? crossref.size : page.columns[2];
515 49 : const actions = crossref ? make_actions_kebab(crossref.actions) : make_page_kebab(page);
516 :
517 110 : if (typeof size === "number") {
518 110 : if (narrow)
519 10 : 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 52 : 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 12 : if (narrow) {
584 12 : rows.push(
585 12 : <Card isPlain key={key}
586 12 : className={"ct-small-table-card" +
587 10 : (is_new ? " ct-new-item" : "")}
588 12 : data-test-row-name={page.name}
589 12 : data-test-row-location={location?.label || location}>
590 12 : <CardBody>
591 12 : <Split hasGutter>
592 10 : { icon && <SplitItem>{icon}</SplitItem> }
593 12 : <SplitItem isFilled>
594 12 : {location_link("id-link", { to: page.location, label: <strong>{name}</strong> })}
595 12 : {info}
596 12 : </SplitItem>
597 12 : <SplitItem>{actions}</SplitItem>
598 12 : </Split>
599 12 : <Split hasGutter isWrappable>
600 12 : <SplitItem>{type}</SplitItem>
601 12 : <SplitItem isFilled>{location_link("location-link", location)}</SplitItem>
602 12 : <SplitItem isFilled className="pf-v6-u-text-align-end">{size}</SplitItem>
603 12 : </Split>
604 12 : </CardBody>
605 12 : </Card>);
606 12 : } 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 24 : <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 16 : return -1;
637 100 : else if (a.category > b.category)
638 52 : 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 97 : 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 49 : make_page_rows(pages, 0, true, "", sorted);
668 49 : else if (crossrefs)
669 49 : 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 24 : firstKeys.current.delete(v);
677 87 : });
678 92 : }
679 :
680 38 : if (rows.length == 0) {
681 38 : return <EmptyState>
682 38 : <EmptyStateBody>
683 38 : {emptyCaption}
684 38 : </EmptyStateBody>
685 38 : </EmptyState>;
686 38 : }
687 :
688 110 : let show_all_button = null;
689 11 : if (rows.length > 50 && collapsed) {
690 11 : show_all_button = (
691 11 : <Bullseye>
692 11 : <Button variant='link'
693 0 : onKeyDown={ev => ev.key === "Enter" && setCollapsed(false)}
694 1 : onClick={() => setCollapsed(false)}>
695 11 : {cockpit.format(_("Show all $0 rows"), rows.length)}
696 11 : </Button>
697 11 : </Bullseye>);
698 11 : rows = rows.slice(0, 50);
699 11 : }
700 :
701 110 : return (
702 110 : <>
703 110 : { narrow
704 12 : ? 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 16 : { (client.in_anaconda_mode() && card.page.parent && !card.next) &&
800 16 : <CardBody>
801 16 : <StorageBreadcrumb page={card.page} />
802 16 : </CardBody>
803 : }
804 94 : <CardHeader actions={{ actions: actions || <Actions actions={card.actions} /> }}>
805 112 : <CardTitle>{card.title}</CardTitle>
806 112 : </CardHeader>
807 24 : {(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 12 : <Page id="storage" className={"pf-m-no-sidebar" + (client.in_anaconda_mode() ? " anaconda" : "")}>
855 102 : { (!client.in_anaconda_mode() && page.parent) &&
856 90 : <PageBreadcrumb hasBodyWrapper={false} stickyOnBreakpoint={{ default: "top" }}>
857 90 : <StorageBreadcrumb page={page} />
858 90 : </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 : };
|