* Copyright (C) 2018 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
To show a modal dialog, make a call like this:
dialog_show({ Title: _("What is your name?"),
Fields: [
TextInput("name", _("Name"),
{ validate: val => (val == ""? _("Name can't be empty") : null) })
]
Action: {
Title: _("Ok"),
action: vals => { console.log("Hello, " + vals.name + "!"); }
}
});
The call to dialog_show will open the dialog and return
immediately. Later, when the user clicks on "Ok", the "action"
function will be called with the values of the dialog fields. The
action function usually returns a promise, although it does not in
the example above. When that promise resolves, the dialog is
closed. When the promise is rejected, it's error is displayed in
the dialog, and the dialog stays open.
Fields are described by calling functions such as TextInput. A
number of generic ones are defined here, and you can define more
specialized ones yourself.
They are all called like this:
FieldFunction(tag, title, { option: value, ... })
The "tag" is used to uniquely identify this field in the dialog.
The action function will receive the values of all fields in an
object, and the tag of a field is the key in that object, for
example. The tag is also used to interact with a field from tests.
ACTION FUNCTIONS
The action function is called like this:
action(values, progress_callback)
The "values" parameter contains the validated values of the dialog
fields and the "progress_callback" can be called by the action function
to update the progress information in the dialog while it runs.
The progress callback should be called like this:
progress_callback(message, cancel_callback)
The "message" will be displayed in the dialog and if "cancel_callback" is
not null, the Cancel button in the dialog will be enabled and
"cancel_callback" will be called when the user clicks it.
The return value of the action function is normally a promise. When
it is resolved, the dialog is closed. When it is rejected the value
given in the rejection is displayed as an error in the dialog.
If the error value is a string, it is displayed as a global failure
message. When it is an object, it contains errors for individual
fields in this form:
{ tag1: message, tag2: message }
As a special case, when "message" is "true", the field is rendered
as having an error (with a red outline, say), but without any
directly associated text. The idea is that a group of fields is in
error, and the error message for all of them is shown below the last
one in the group.
COMMON FIELD OPTIONS
Each field function describes its options. However, there are some
options that apply to all fields:
- value
The initial value of the field.
- visible: vals -> boolean
This function determines whether the field is shown or not.
- validate: (val, vals) -> null-or-error-string (or promise)
The validate function receives the current value of the field and
should return "null" (or something falsey) when that value is
acceptable. Otherwise, it should return a suitable error message.
The second argument has all values of all fields, in case you need
to look at more than one field.
It is permissible to overwrite fields of "vals" to change the final
value of a field.
The validate function can also return a promise which resolves to
null or an error message. If that promise is rejected, that error
is shown globally in the dialog as if the action function had
failed.
The validate function will only be called for currently visible
fields.
- widest_title
This is a hack to force the column of titles to be a certain
minimum width, namely the width of the widest_title. This matters
when there are rows that are only sometimes visible and the layout
would jump around when they change visibility.
Technically, the first column of a row shows the "title" but is as
wide as its "widest_title". The idea is that you put the widest
title of all fields in the widest_title option of one of the rows
that are always visible.
- explanation
A test to show below the field, as an explanation.
RUNNING TASKS AND DYNAMIC UPDATES
The dialog_show function returns an object that can be used to interact
with the dialog in various ways while it is open.
dlg = dialog_show(...)
One can run asynchronous tasks:
dlg.run("title", promise)
This will disable the footer buttons and wait for promise to be resolved
or rejected while showing "title" and a spinner.
One can set field values and options:
dlg.set_values({ tag1: value1, tag2: value2, ... })
dlg.set_options(tag, { opt1: value1, opt2: value2, ... })
It is also possible to specify a "update" function when creating the dialog:
dialog_show({ ...
update: function (dlg, vals, trigger) { }
... })
This function is called whenever the values of fields are changed. The
"trigger" argument is the tag of the field that has just been changed.
DEFINING NEW FIELD TYPES
To define a new field type, just define a new function that follows
a few rules. Here is TextInput:
export const TextInput = (tag, title, options) => {
return {
tag: tag,
title: title,
options: options,
initial_value: "",
render: (val, change) =>
<input data-field={tag}
className="form-control" type="text" value={val}
onChange={event => change(event.target.value)}/>
}
}
As you can see, a field function should return an object with a
couple of fields. The "tag", "title", and "options" field just
store the parameters to the field function. The rest are these:
- initial_value
This is the initial value of the field.
- render: (val, change) -> React components
This should render the value part of the field, that is, the second
column in the table layout. The title is in the first column and
is rendered by the generic dialog machinery.
The "val" parameter is the current value and you should make sure
that the DOM element really shows that value, and not something
that might have left behind by previous user interactions.
The "change" parameter is a function that should be called with a
new value for the field whenever the user has interacted with it.
For the benefits of the integration tests, the DOM elements should
also contain "data-field" and maybe a "data-field-type" attributes. The
"data-field" value should be that tag of the field, and
"data-field-type" type is used by the tests to know how to interact
with the field. If you find to need it, just pick a reasonable value
and extend the test suite to handle it.
This function is not called at all for invisible fields.
*/
import cockpit from "cockpit";
import React, { useState } from "react";
import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
import { DataList, DataListCell, DataListCheck, DataListItem, DataListItemCells, DataListItemRow } from "@patternfly/react-core/dist/esm/components/DataList/index.js";
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
import { Grid, GridItem } from "@patternfly/react-core/dist/esm/layouts/Grid/index.js";
import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js";
import { Slider } from "@patternfly/react-core/dist/esm/components/Slider/index.js";
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
import { Split } from "@patternfly/react-core/dist/esm/layouts/Split/index.js";
import { TextInput as TextInputPF4 } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List/index.js";
import { ExclamationTriangleIcon, InfoIcon, HelpIcon, EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
import { InputGroup } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js";
import { Table, Tbody, Tr, Td } from '@patternfly/react-table';
import { TypeaheadSelect } from "cockpit-components-typeahead-select";
import { show_modal_dialog, apply_modal_dialog } from "cockpit-components-dialog.jsx";
import { ListingTable } from "cockpit-components-table.jsx";
import { FormHelper } from "cockpit-components-form-helper";
import {
decode_filename, fmt_size, block_name, format_size_and_text, format_delay, for_each_async, get_byte_units,
BTRFS_TOOL_MOUNT_PATH
} from "./utils.js";
import { fmt_to_fragments } from "utils.jsx";
import client from "./client.js";
import fsys_is_empty_sh from "./fsys-is-empty.sh";
const _ = cockpit.gettext;
function make_rows(fields, values, errors, onChange) {
return fields.map((f, i) => <Row key={i} field={f} values={values} errors={errors} onChange={onChange} />)
.filter(r => r);
}
function is_visible(field, values) {
return !field.options || field.options.visible === undefined || field.options.visible(values);
}
const Field = ({ field, values, errors, onChange }) => {
const { tag, options } = field;
if (!is_visible(field, values))
return null;
const error = errors && errors[tag];
const explanation = options && options.explanation;
const validated = (tag && errors && errors[tag]) ? 'error' : 'default';
function change(val) {
values[tag] = val;
onChange(tag);
}
return (
<>
{field.render(values[tag], change, validated, error)}
<FormHelper helperText={explanation} helperTextInvalid={validated && error} />
</>);
};
const Row = ({ field, values, errors, onChange }) => {
const { title, options } = field;
if (!is_visible(field, values))
return null;
const field_elts = <Field field={field} values={values} errors={errors} onChange={onChange} />;
let nested_elts = [];
if (options && options.nested_fields) {
if (field.is_group)
nested_elts = options.nested_fields.map(f => <Field key={f.tag}
field={f}
values={values}
errors={errors}
onChange={onChange} />);
else
nested_elts = make_rows(options.nested_fields, values, errors, onChange);
}
if (title || title === "") {
let titleLabel = title;
if (options.widest_title)
titleLabel = (
<>
<div className="widest-title">{options.widest_title}</div>
<div>{title}</div>
</>
);
return (
<FormGroup label={titleLabel} hasNoPaddingTop={field.hasNoPaddingTop}>
{ field_elts }
{ nested_elts }
</FormGroup>
);
} else if (!field.bare) {
return (
<FormGroup hasNoPaddingTop={field.hasNoPaddingTop}>
{ field_elts }
{ nested_elts }
</FormGroup>
);
} else
return field_elts;
};
const Body = ({ body, teardown, fields, values, errors, isFormHorizontal, onChange }) => {
let error_alert = null;
if (errors && errors.toString() !== "[object Object]") {
error_alert = <Alert variant='danger' isInline title={errors.toString()} />;
errors = null;
}
return (
<>
{ error_alert }
{ body || null }
{ fields.length > 0
? <Form onSubmit={apply_modal_dialog}
isHorizontal={isFormHorizontal !== false}>
{ make_rows(fields, values, errors, onChange) }
</Form>
: null }
{ teardown }
</>
);
};
const ExtraConfirmation = ({ text, onChange }) => {
const [confirmed, setConfirmed] = useState(false);
return (
<Checkbox isChecked={confirmed}
id="dialog-confirm"
label={text}
onChange={(_, val) => {
setConfirmed(val);
onChange(val);
}} />);
};
function flatten_fields(fields) {
return fields.reduce(
(acc, val) => acc.concat([val]).concat(val.options && val.options.nested_fields
? flatten_fields(val.options.nested_fields)
: []),
[]);
}
export const dialog_open = (def) => {
const nested_fields = def.Fields || [];
const fields = flatten_fields(nested_fields);
const values = { };
let confirmation = null;
let confirmed = false;
let errors = null;
fields.forEach(f => { values[f.tag] = f.initial_value });
const update = () => {
dlg.setProps(props());
};
const props = () => {
return {
id: "dialog",
title: def.Title,
titleIconVariant: (def.Action && (def.Action.Danger || def.Action.DangerButton)) ? "warning" : null,
body: <Body body={def.Body}
teardown={def.Teardown}
fields={nested_fields}
values={values}
errors={errors}
isFormHorizontal={def.isFormHorizontal}
onChange={trigger => {
errors = null;
if (def.update)
def.update(self, values, trigger);
update();
}} />
};
};
const update_footer = (running_title, running_promise) => {
dlg.setFooterProps(footer_props(running_title, running_promise));
};
function run_action(progress_callback, variant) {
const func = () => {
return validate(variant)
.then(validated_values => {
const visible_values = { variant };
fields.forEach(f => {
if (is_visible(f, values))
visible_values[f.tag] = validated_values[f.tag];
});
if (def.Action.wrapper)
return def.Action.wrapper(visible_values, progress_callback,
def.Action.action);
else
return def.Action.action(visible_values, progress_callback);
})
.catch(errs => {
if (errs && errs.toString() !== "[object Object]") {
console.warn(errs.toString());
}
errors = errs;
update();
update_footer();
return Promise.reject();
});
};
return client.run(func);
}
const footer_props = (running_title, running_promise) => {
const actions = [];
function add_action(variant) {
actions.push({
caption: variant.Title,
style: actions.length === 0 ? "primary" : "secondary",
danger: def.Action.Danger || def.Action.DangerButton,
disabled: (running_promise != null ||
(def.Action.disable_on_error &&
errors && errors.toString() !== "[object Object]") ||
(confirmation && !confirmed)),
clicked: progress_callback => run_action(progress_callback, variant.tag),
});
}
if (def.Action) {
if (def.Action.Title) {
add_action({
Title: def.Action.Title,
tag: null,
});
}
if (def.Action.Variants) {
for (const v of def.Action.Variants) {
add_action(v);
}
}
}
let extra = null;
if (confirmation) {
extra = <ExtraConfirmation text={confirmation}
onChange={val => {
confirmed = val;
update_footer();
}} />;
} else if (def.Action && def.Action.Danger) {
extra = (
<div>
<HelperText><HelperTextItem variant="error">{def.Action.Danger} </HelperTextItem></HelperText>
</div>);
}
return {
idle_message: (running_promise
? <>
<span>{running_title}</span>
<Spinner className="dialog-wait-ct-spinner" size="md" />
</>
: null),
extra_element: extra,
actions,
cancel_button: def.Action ? {} : { text: _("Close"), variant: "secondary" }
};
};
const validate = (variant) => {
const validated_values = { ...values };
return Promise.all(fields.map(f => {
if (is_visible(f, values) && f.options && f.options.validate)
return f.options.validate(validated_values[f.tag], validated_values, variant);
else
return null;
})).then(results => {
const errors = { };
let scrolled = false;
fields.forEach((f, i) => {
if (results[i]) {
if (!scrolled) {
show_field(f.tag);
scrolled = true;
}
errors[f.tag] = results[i];
}
});
if (Object.keys(errors).length > 0)
return Promise.reject(errors);
return validated_values;
});
};
const dlg = show_modal_dialog(props(), footer_props(null, null));
function show_field(tag) {
function scroll() {
const field_element = document.querySelector('#dialog [data-field="' + tag + '"]');
if (field_element)
field_element.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
window.setTimeout(scroll, 10);
}
const self = {
run: (title, promise) => {
update_footer(title, promise);
promise.then(
() => {
update_footer(null, null);
},
(errs) => {
if (errs) {
errors = errs;
update();
}
update_footer(null, null);
});
},
set_values: (new_vals) => {
Object.assign(values, new_vals);
update();
},
get_value: (tag) => {
return values[tag];
},
update_actions: (new_actions) => {
Object.assign(def.Action, new_actions);
update_footer(null, null);
},
set_nested_values: (key, new_vals) => {
const updated = values[key];
Object.assign(updated, new_vals);
values[key] = updated;
update();
},
get_options: (tag) => {
for (const f of fields) {
if (f.tag === tag) {
return f.options;
}
}
},
set_options: (tag, new_options) => {
fields.forEach(f => {
if (f.tag === tag) {
Object.assign(f.options, new_options);
update();
}
});
},
set_attribute: (name, value) => {
def[name] = value;
update();
},
add_danger: (danger) => {
def.Action.Danger = <>{def.Action.Danger} {danger}</>;
update();
},
need_confirmation: (conf) => {
confirmation = conf;
confirmed = false;
def.Action.Danger = null;
def.Action.DangerButton = true;
update_footer();
},
show_field,
close: () => {
dlg.footerProps.dialog_done();
}
};
for_each_async(def.Inits || [],
init => {
if (init) {
const promise = init.func(self);
self.run(init.title, promise);
return promise;
} else
return Promise.resolve();
});
return self;
};
*/
export const TextInput = (tag, title, options) => {
return {
tag,
title,
options,
initial_value: options.value || "",
render: (val, change, validated) =>
<TextInputPF4 data-field={tag} data-field-type="text-input"
validated={validated}
aria-label={title}
value={val}
isDisabled={options.disabled}
onChange={(_event, value) => change(value)} />
};
};
const PassInputElement = ({ tag, title, options, val, change, validated }) => {
const [show, setShow] = useState(false);
return (
<InputGroup>
<TextInputPF4 data-field={tag} data-field-type="text-input"
validated={validated}
disabled={options.disabled}
aria-label={title}
autoComplete={options.new_password ? "new-password" : null}
type={show ? "text" : "password"}
value={val}
onChange={(_event, value) => change(value)} />
<Button variant="control"
onClick={() => setShow(!show)}
isDisabled={options.disabled}>
{ show ? <EyeSlashIcon /> : <EyeIcon /> }
</Button>
</InputGroup>);
};
export const PassInput = (tag, title, options) => {
return {
tag,
title,
options,
initial_value: options.value || "",
render: (val, change, validated) =>
<PassInputElement tag={tag}
title={title}
options={options}
val={val}
change={change}
validated={validated} />
};
};
const TypeAheadSelectElement = ({ value, options, change }) => {
return (
<TypeaheadSelect toggleProps={ { id: "nfs-path-on-server" } }
isScrollable
isCreatable
createOptionMessage={val => cockpit.format(_("Use $0"), val)}
selected={value}
onSelect={(_, value) => change(value)}
onClearSelection={() => change("")}
isDisabled={options.disabled}
selectOptions={options.choices.map(entry => ({ value: entry, content: entry }))} />
);
};
export const ComboBox = (tag, title, options) => {
return {
tag,
title,
options,
initial_value: options.value || "",
render: (val, change, validated) => {
return (
<div data-field={tag} data-field-type="combobox">
<TypeAheadSelectElement value={val} options={options} change={change} />
</div>
);
}
};
};
export const SelectOne = (tag, title, options) => {
return {
tag,
title,
options,
initial_value: options.value || options.choices[0].value,
render: (val, change, validated) => {
return (
<div data-field={tag} data-field-type="select" data-value={val}>
<FormSelect value={val} aria-label={tag}
validated={validated}
onChange={(_, value) => change(value)}>
{ options.choices.map(c => <FormSelectOption value={c.value} isDisabled={c.disabled}
key={c.title} label={c.title} />) }
</FormSelect>
</div>
);
}
};
};
export const SelectOneRadio = (tag, title, options) => {
return {
tag,
title,
options,
initial_value: options.value || options.choices[0].value,
hasNoPaddingTop: true,
render: (val, change) => {
const vertical = options?.vertical || false;
const fields = options.choices.map(c => (
<Radio key={c.value} isChecked={val === c.value} data-data={c.value}
id={tag + '.' + c.value}
onChange={() => change(c.value)} label={c.title} />));
if (vertical) {
return (
<div data-field={tag} data-field-type="select-radio">
{fields}
</div>);
} else {
return (
<Split hasGutter data-field={tag} data-field-type="select-radio">
{fields}
</Split>);
}
}
};
};
export const SelectRow = (tag, headers, options) => {
return {
tag,
title: null,
options,
initial_value: options.value || options.choices[0].value,
render: (val, change) => {
return (
<table data-field={tag} data-field-type=" select-row" className="dialog-select-row-table">
<thead>
<tr>{headers.map(h => <th key={h}>{h}</th>)}</tr>
</thead>
<tbody>
{ options.choices.map(row => {
return (
<tr key={row.value}
onMouseDown={ev => { if (ev && ev.button === 0) change(row.value); }}
className={row.value === val ? "highlight-ct" : ""}>
{row.columns.map(c => <td key={c}>{c}</td>)}
</tr>
);
})
}
</tbody>
</table>
);
}
};
};
function nice_block_name(block) {
return block_name(client.blocks[block.CryptoBackingDevice] || block);
}
export const SelectSpaces = (tag, title, options) => {
return {
tag,
title,
options,
initial_value: options.value || [],
hasNoPaddingTop: options.spaces.length === 0,
render: (val, change) => {
if (options.spaces.length === 0)
return <span className="text-danger">{options.empty_warning}</span>;
return (
<DataList isCompact
data-field={tag} data-field-type="select-spaces">
{ options.spaces.map(spc => {
const selected = (val.indexOf(spc) >= 0);
const block = spc.block ? nice_block_name(spc.block) : "";
const desc = block === spc.desc ? "" : spc.desc;
const on_change = (_event, checked) => {
if (checked && !selected)
change(options.spaces.filter(v => val.indexOf(v) >= 0 || v === spc));
else if (!checked && selected)
change(val.filter(v => (v !== spc)));
};
const datalistcells = (
<DataListItemCells
dataListCells={[
<DataListCell key="select-space-name" className="select-space-name">
{format_size_and_text(spc.size, desc)}
</DataListCell>,
<DataListCell alignRight isFilled={false} key="select-space-details" className="select-space-details">
{block}
</DataListCell>,
]}
/>);
const key = block || desc;
return (
<DataListItem data-space-name={key} key={key}>
<DataListItemRow>
<DataListCheck id={(spc.block ? spc.block.Device : spc.desc) + "-row-checkbox"}
isDisabled={options.min_selected &&
selected && val.length <= options.min_selected}
isChecked={selected} onChange={on_change} />
<label htmlFor={(spc.block ? spc.block.Device : spc.desc) + "-row-checkbox"}
className='data-list-row-checkbox-label'>
{datalistcells}
</label>
</DataListItemRow>
</DataListItem>
);
})
}
</DataList>
);
}
};
};
export const SelectSpace = (tag, title, options) => {
return {
tag,
title,
options,
initial_value: null,
render: (val, change) => {
if (options.spaces.length === 0)
return <span className="text-danger">{options.empty_warning}</span>;
return (
<DataList isCompact
data-field={tag} data-field-type="select-spaces">
{ options.spaces.map(spc => {
const block = spc.block ? nice_block_name(spc.block) : "";
const desc = block === spc.desc ? "" : spc.desc;
const on_change = (event) => {
if (event.target.checked)
change(spc);
};
const key = block || desc;
return (
<DataListItem data-space-name={key} key={key}>
<DataListItemRow>
<div className="pf-v6-c-data-list__item-control">
<div className="pf-v6-c-data-list__check">
<input type='radio' value={desc} name='space' checked={val === spc} onChange={on_change} />
</div>
</div>
<DataListItemCells
dataListCells={[
<DataListCell key="select-space-name" className="select-space-name">
{format_size_and_text(spc.size, desc)}
</DataListCell>,
<DataListCell alignRight isFilled={false} key="select-space-details" className="select-space-details">
{block}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
})
}
</DataList>
);
}
};
};
const CheckBoxComponent = ({ tag, val, title, tooltip, disabled, update_function }) => {
return (
<Checkbox data-field={tag} data-field-type="checkbox"
id={tag}
isChecked={val}
isDisabled={disabled}
label={
<>
{title}
{ tooltip && <Popover bodyContent={tooltip}>
<Button icon={<HelpIcon />} className="dialog-item-tooltip" variant="link" />
</Popover>
}
</>
}
onChange={(_, v) => update_function(v)} />
);
};
export const CheckBoxes = (tag, title, options) => {
return {
tag,
title,
options,
initial_value: options.value || { },
hasNoPaddingTop: true,
render: (val, change) => {
const fieldset = options.fields.map(field => {
const ftag = tag + "." + field.tag;
const fval = (val[field.tag] !== undefined) ? val[field.tag] : false;
function fchange(newval) {
val[field.tag] = newval;
change(val);
}
if (field.type === undefined || field.type === "checkbox")
return <CheckBoxComponent key={`checkbox-${ftag}`}
tag={ftag}
val={fval}
title={field.title}
disabled={field.disabled}
tooltip={field.tooltip}
options={options}
update_function={fchange} />;
else if (field.type === "checkboxWithInput")
return <TextInputCheckedComponent key={`checkbox-with-text-${ftag}`}
tag={ftag}
val={fval}
title={field.title}
update_function={fchange} />;
else
return null;
});
if (options.fields.length === 1)
return fieldset;
return <>{ fieldset }</>;
}
};
};
const TextInputCheckedComponent = ({ tag, val, title, update_function }) => {
return (
<div data-field={tag} data-field-type="text-input-checked" key={tag}>
<Checkbox isChecked={val !== false}
id={tag}
label={title}
onChange={(_event, checked) => update_function(checked ? "" : false)} />
{val !== false && <TextInputPF4 id={tag + "-input"} value={val} onChange={(_event, value) => update_function(value)} />}
</div>
);
};
export const Skip = (className, options) => {
return {
tag: false,
title: null,
options,
initial_value: false,
render: () => {
return <div className={className} />;
}
};
};
export const Message = (text, options) => {
return {
options,
render: () => <HelperText><HelperTextItem icon={<InfoIcon />}>{text}</HelperTextItem></HelperText>,
};
};
function size_slider_round(value, round) {
if (round) {
if (typeof round == "function")
value = round(value);
else
value = Math.round(value / round) * round;
} else {
value = Math.round(value);
}
return value;
}
class SizeSliderElement extends React.Component {
constructor(props) {
super();
this.units = get_byte_units(props.value || props.max);
this.state = { unit: this.units.find(u => u.selected).factor };
}
render() {
const { val, max, round, onChange, tag } = this.props;
const min = this.props.min || 0;
const { unit } = this.state;
const change_slider = (_event, f) => {
onChange(Math.max(min, size_slider_round(f, round)));
};
const change_text = (value) => {
* interpret it below in the validate function inside
* SizeSlider. This allows people to freely interact with
* the text input without getting the text changed all the
* time by rounding, etc.
*/
onChange({ text: value, unit });
};
let slider_val;
let text_val;
if (val.text && val.unit) {
slider_val = Number(val.text) * val.unit;
text_val = val.text;
} else {
slider_val = val;
text_val = cockpit.format_number(val / unit);
}
const change_unit = (_, u) => {
if (val.unit)
onChange({ text: val.text, unit: Number(u) });
else
onChange(size_slider_round(val / unit * Number(u), round));
this.setState({ unit: Number(u) });
};
return (
<Grid hasGutter className="size-slider">
<GridItem span={12} sm={8}>
<Slider showBoundaries={false} min={min} max={max} step={(max - min) / 500}
value={slider_val} onChange={change_slider} />
</GridItem>
<GridItem span={6} sm={2}>
<TextInputPF4 className="size-text" value={text_val} onChange={(_event, value) => change_text(value)} />
</GridItem>
<GridItem span={6} sm={2}>
<FormSelect className="size-unit" value={unit} aria-label={tag} onChange={change_unit}>
{ this.units.map(u => <FormSelectOption value={u.factor} key={u.name} label={u.name} />) }
</FormSelect>
</GridItem>
</Grid>
);
}
}
export const SizeSlider = (tag, title, options) => {
const validate = (val, vals) => {
let msg = null;
if (val.text && val.unit) {
const unit = val.unit;
val = Number(val.text) * unit;
const sanitize = (limit) => {
const fmt = cockpit.format_number(limit / unit);
const parse = +fmt * unit;
if (val === parse)
val = limit;
};
sanitize(all_options.min || 0);
sanitize(all_options.max);
val = size_slider_round(val, all_options.round);
vals[tag] = val;
}
if (isNaN(val))
msg = _("Size must be a number");
else if (val === 0)
msg = _("Size cannot be zero");
else if (val < 0)
msg = _("Size cannot be negative");
else if (!options.allow_infinite && val > options.max)
msg = _("Size is too large");
else if (options.min !== undefined && val < options.min)
msg = cockpit.format(_("Size must be at least $0"), fmt_size(options.min));
else if (options.validate)
msg = options.validate(val, vals);
return msg;
};
have to use it below for the 'max' option in order to pick up
changes to it.
*/
const all_options = Object.assign({ }, options, { validate });
return {
tag,
title,
options: all_options,
initial_value: options.value || options.max || 0,
render: (val, change) => {
return (
<div data-field={tag} data-field-type="size-slider">
<SizeSliderElement val={val}
max={all_options.max}
min={all_options.min}
round={all_options.round}
tag={tag}
onChange={change} />
</div>
);
}
};
};
export const Group = (title, fields) => {
return {
tag: null,
title,
is_group: true,
hasNoPaddingTop: true,
options: { nested_fields: fields },
render: (val, change) => null,
};
};
export const BlockingMessage = (usage) => {
const usage_desc = {
pvol: _("physical volume of LVM2 volume group"),
"mdraid-member": _("member of MDRAID device"),
vdo: _("backing device for VDO device"),
"stratis-pool-member": _("member of Stratis pool"),
mounted: _("Filesystem outside the target"),
"btrfs-device": _("device of btrfs volume"),
};
const rows = [];
usage.forEach(use => {
if (use.blocking && use.block) {
const name = teardown_block_name(use);
rows.push({
columns: [name, use.location || "-", usage_desc[use.usage] || "-"]
});
}
});
return (
<div>
<HelperText><HelperTextItem variant="warning">{_("This device is currently in use.")}</HelperTextItem></HelperText>
<ListingTable variant='compact'
columns={[
{ title: _("Device") },
{ title: _("Location") },
{ title: _("Use") }
]}
rows={rows} />
</div>);
};
const UsersPopover = ({ users }) => {
const max = 10;
const services = users.filter(u => u.unit);
const processes = users.filter(u => u.pid);
return (
<Popover
appendTo={document.body}
bodyContent={
<>
{ services.length > 0
? <>
<p><b>{_("Services using the location")}</b></p>
<List>
{ services.slice(0, max).map((u, i) => <ListItem key={i}>{u.unit.replace(/\.service$/, "")}</ListItem>) }
{ services.length > max ? <ListItem key={max}>...</ListItem> : null }
</List>
</>
: null
}
{ services.length > 0 && processes.length > 0
? <br />
: null
}
{ processes.length > 0
? <>
<p><b>{_("Processes using the location")}</b></p>
<List>
{ processes.slice(0, max).map((u, i) => <ListItem key={i}>{u.comm} (user: {u.user}, pid: {u.pid})</ListItem>) }
{ processes.length > max ? <ListItem key={max}>...</ListItem> : null }
</List>
</>
: null
}
</>}>
<Button icon={<ExclamationTriangleIcon className="ct-icon-exclamation-triangle" />} variant="link" style={{ visibility: users.length === 0 ? "hidden" : null }}>
{ "\n" }
{_("Currently in use")}
</Button>
</Popover>);
};
function is_expected_unmount(usage, expect_single_unmount) {
return (expect_single_unmount && usage.length === 1 &&
usage[0].usage === "mounted" && usage[0].location === expect_single_unmount);
}
const teardown_block_name = use => {
const block_stratis = client.blocks_stratis_fsys[use.block.path];
const block_btrfs = client.blocks_fsys_btrfs[use.block.path];
let name;
if (block_stratis) {
name = block_stratis.Devnode;
} else if (block_btrfs && use.name) {
name = use.name;
} else {
name = block_name(client.blocks[use.block.CryptoBackingDevice] || use.block);
}
return name.replace(/^\/dev\//, "");
};
export const TeardownMessage = (usage, expect_single_unmount) => {
if (!usage.Teardown)
return null;
if (client.in_anaconda_mode() && !expect_single_unmount)
return <AnacondaTeardownMessage usage={usage} />;
if (is_expected_unmount(usage, expect_single_unmount))
return <StopProcessesMessage mount_point={expect_single_unmount} users={usage[0].users} />;
const rows = [];
usage.forEach((use, index) => {
if (use.block) {
const name = teardown_block_name(use);
let location = use.location;
* It's fine to tear them down, but we don't want people
* to start worrying about them.
*/
if (location && location.startsWith(BTRFS_TOOL_MOUNT_PATH))
return;
if (use.usage === "mounted") {
location = client.strip_mount_point_prefix(location);
if (location === false)
location = _("(Not part of target)");
}
rows.push({
columns: [name,
location || "-",
use.actions.length ? use.actions.join(", ") : "-",
{
title: <UsersPopover users={use.users || []} />,
props: { className: "pf-v6-u-text-align-right" }
}
]
});
}
});
if (rows.length === 0)
return null;
return (
<div className="modal-footer-teardown">
<p>{_("These changes will be made:")}</p>
<ListingTable variant='compact'
columns={[
{ title: _("Device") },
{ title: _("Location") },
{ title: _("Action") },
{ title: "" }
]}
rows={rows} />
</div>);
};
const AnacondaTeardownMessage = ({ usage }) => {
const rows = [];
usage.forEach((use, index) => {
if (use.data_warning) {
const name = teardown_block_name(use);
const location = client.strip_mount_point_prefix(use.location) || use.block.IdLabel || "-";
rows.push(
<Tr key={index}>
<Td className="pf-v6-u-font-weight-bold">{name}</Td>
<Td>{location}</Td>
<Td>{use.data_warning}</Td>
</Tr>);
}
});
if (rows.length > 0) {
return (
<div className="modal-footer-teardown">
<HelperText>
<HelperTextItem variant="error">
{_("Important data might be deleted:")}
</HelperTextItem>
</HelperText>
<Table variant="compact" borders={false}><Tbody>{rows}</Tbody></Table>
</div>);
}
};
export function teardown_danger_message(usage, expect_single_unmount) {
if (is_expected_unmount(usage, expect_single_unmount))
return stop_processes_danger_message(usage[0].users);
const usage_with_users = usage.filter(u => u.users);
const n_processes = usage_with_users.reduce((sum, u) => sum + u.users.filter(u => u.pid).length, 0);
const n_services = usage_with_users.reduce((sum, u) => sum + u.users.filter(u => u.unit).length, 0);
if (n_processes > 0 && n_services > 0) {
return _("Related processes and services will be forcefully stopped.");
} else if (n_processes > 0) {
return _("Related processes will be forcefully stopped.");
} else if (n_services > 0) {
return _("Related services will be forcefully stopped.");
} else {
return null;
}
}
export function init_teardown_usage(client, usage, expect_single_unmount) {
return {
title: _("Checking filesystem usage"),
func: async function (dlg) {
let have_data = false;
for (const u of usage) {
if (u.usage === "mounted") {
u.users = await client.find_mount_users(u.location);
}
if (client.in_anaconda_mode() && !expect_single_unmount && u.block) {
if (u.block.IdUsage === "filesystem" &&
["xfs", "ext2", "ext3", "ext4", "btrfs", "vfat", "ntfs"].indexOf(u.block.IdType) >= 0) {
const empty = await cockpit.script(fsys_is_empty_sh,
[decode_filename(u.block.PreferredDevice)],
{ superuser: "require", err: "message" });
if (empty.trim() !== "yes") {
try {
const info = JSON.parse(empty);
u.data_warning = cockpit.format(_("$0 used, $1 total"),
fmt_size((info.total - info.free) * info.unit),
fmt_size(info.total * info.unit));
} catch {
u.data_warning = _("Device contains unrecognized data");
}
}
} else if (u.block.IdUsage === "crypto" && !client.blocks_cleartext[u.block.path]) {
u.data_warning = _("Locked encrypted device might contain data");
} else if (!client.blocks_ptable[u.block.path] &&
u.block.IdUsage && u.block.IdUsage !== "raid") {
u.data_warning = _("Device contains unrecognized data");
}
if (u.data_warning)
have_data = true;
}
}
if (have_data) {
usage.Teardown = true;
dlg.need_confirmation(_("I confirm I want to lose this data forever"));
} else if (client.in_anaconda_mode() && !expect_single_unmount) {
dlg.need_confirmation(null);
} else {
const msg = teardown_danger_message(usage, expect_single_unmount);
if (msg)
dlg.add_danger(msg);
}
dlg.set_attribute("Teardown", TeardownMessage(usage, expect_single_unmount));
}
};
}
export const StopProcessesMessage = ({ mount_point, users }) => {
if (!users || users.length === 0)
return null;
const process_rows = users.filter(u => u.pid).map(u => {
return {
columns: [
u.pid,
{ title: u.cmd.substring(0, 100), props: { modifier: "breakWord" } },
u.user || "-",
{ title: format_delay(-u.since * 1000), props: { modifier: "nowrap" } }
]
};
});
const service_rows = users.filter(u => u.unit).map(u => {
return {
columns: [
{ title: u.unit.replace(/\.service$/, ""), props: { modifier: "breakWord" } },
{ title: u.cmd.substring(0, 100), props: { modifier: "breakWord" } },
{ title: u.desc || "", props: { modifier: "breakWord" } },
{ title: format_delay(-u.since * 1000), props: { modifier: "nowrap" } }
]
};
});
const colprops = (process_rows.length > 0 && service_rows.length > 0) ? { width: 25 } : { };
return (
<div className="modal-footer-teardown">
{ process_rows.length > 0
? <>
<p>{fmt_to_fragments(_("The mount point $0 is in use by these processes:"), <b>{mount_point}</b>)}</p>
<ListingTable variant='compact'
columns={
[
{ title: _("PID"), props: colprops },
{ title: _("Command"), props: colprops },
{ title: _("User"), props: colprops },
{ title: _("Started"), props: colprops }
]
}
rows={process_rows} />
</>
: null
}
{ process_rows.length > 0 && service_rows.length > 0
? <br />
: null
}
{ service_rows.length > 0
? <>
<p>{fmt_to_fragments(_("The mount point $0 is in use by these services:"), <b>{mount_point}</b>)}</p>
<ListingTable variant='compact'
columns={
[
{ title: _("Service"), props: colprops },
{ title: _("Command"), props: colprops },
{ title: _("Description"), props: colprops },
{ title: _("Started"), props: colprops }
]
}
rows={service_rows} />
</>
: null
}
</div>);
};
export const stop_processes_danger_message = (users) => {
const n_processes = users.filter(u => u.pid).length;
const n_services = users.filter(u => u.unit).length;
if (n_processes > 0 && n_services > 0)
return _("The listed processes and services will be forcefully stopped.");
else if (n_processes > 0)
return _("The listed processes will be forcefully stopped.");
else if (n_services > 0)
return _("The listed services will be forcefully stopped.");
else
return null;
};