1243 lines
45 KiB
JavaScript
1243 lines
45 KiB
JavaScript
/*
|
|
* This file is part of Cockpit.
|
|
*
|
|
* Copyright (C) 2018 Red Hat, Inc.
|
|
*
|
|
* Cockpit is free software; you can redistribute it and/or modify it
|
|
* under the terms of the GNU Lesser General Public License as published by
|
|
* the Free Software Foundation; either version 2.1 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Cockpit is distributed in the hope that it will be useful, but
|
|
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
* Lesser General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Lesser General Public License
|
|
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
/* STORAGE DIALOGS
|
|
|
|
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,
|
|
FormSelect, FormSelectOption,
|
|
Button,
|
|
Checkbox,
|
|
DataList, DataListItem, DataListCheck, DataListItemRow, DataListItemCells, DataListCell,
|
|
Form, FormGroup,
|
|
Grid, GridItem,
|
|
Radio,
|
|
Select as TypeAheadSelect, SelectOption, SelectVariant,
|
|
Slider,
|
|
Spinner, Split,
|
|
TextInput as TextInputPF4,
|
|
Popover,
|
|
HelperText, HelperTextItem,
|
|
List, ListItem
|
|
} from "@patternfly/react-core";
|
|
import { ExclamationTriangleIcon, InfoIcon, HelpIcon } from "@patternfly/react-icons";
|
|
|
|
import { show_modal_dialog, apply_modal_dialog } from "cockpit-components-dialog.jsx";
|
|
import { ListingTable } from "cockpit-components-table.jsx";
|
|
|
|
import { fmt_size, block_name, format_size_and_text, format_delay, for_each_async } from "./utils.js";
|
|
import { fmt_to_fragments } from "utils.jsx";
|
|
import client from "./client.js";
|
|
|
|
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 Row = ({ field, values, errors, onChange }) => {
|
|
const { tag, title, 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);
|
|
}
|
|
|
|
const field_elts = field.render(values[tag], change, validated, error);
|
|
const nested_elts = (options && options.nested_fields
|
|
? 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} validated={validated}
|
|
helperTextInvalid={error} helperText={explanation} hasNoPaddingTop={field.hasNoPaddingTop}>
|
|
{ field_elts }
|
|
{ nested_elts }
|
|
</FormGroup>
|
|
);
|
|
} else if (!field.bare) {
|
|
return (
|
|
<FormGroup validated={validated}
|
|
helperTextInvalid={error} helperText={explanation} 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]") {
|
|
// This is a global error from a failed action
|
|
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 }
|
|
</>
|
|
);
|
|
};
|
|
|
|
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 errors = null;
|
|
|
|
fields.forEach(f => { values[f.tag] = f.initial_value });
|
|
|
|
// We reconstruct the body every time the values change so that it
|
|
// will be re-rendered. This could be done with some state in the
|
|
// Body component maybe, but we also want the values up here so
|
|
// that we can pass them to validate and the action function.
|
|
|
|
const update = () => {
|
|
dlg.setProps(props());
|
|
};
|
|
|
|
const props = () => {
|
|
const title = (def.Action && (def.Action.Danger || def.Action.DangerButton)
|
|
? <><ExclamationTriangleIcon className="ct-icon-exclamation-triangle" /> {def.Title}</>
|
|
: def.Title);
|
|
return {
|
|
id: "dialog",
|
|
title: title,
|
|
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));
|
|
};
|
|
|
|
const footer_props = (running_title, running_promise) => {
|
|
let actions = [];
|
|
if (def.Action) {
|
|
actions = [
|
|
{
|
|
caption: def.Action.Title,
|
|
style: (def.Action.Danger || def.Action.DangerButton) ? "danger" : "primary",
|
|
disabled: running_promise != null,
|
|
clicked: function (progress_callback) {
|
|
const func = () => {
|
|
return validate()
|
|
.then(() => {
|
|
const visible_values = { };
|
|
fields.forEach(f => {
|
|
if (is_visible(f, values))
|
|
visible_values[f.tag] = 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]") {
|
|
// Log errors from failed actions, for debugging and
|
|
// to allow the test suite to catch known issues.
|
|
console.warn(errs.toString());
|
|
}
|
|
errors = errs;
|
|
update();
|
|
return Promise.reject();
|
|
});
|
|
};
|
|
return client.run(func);
|
|
}
|
|
}
|
|
];
|
|
}
|
|
|
|
const extra = (
|
|
<div>
|
|
{ def.Action && def.Action.Danger
|
|
? <HelperText><HelperTextItem variant="error">{def.Action.Danger} </HelperTextItem></HelperText>
|
|
: null
|
|
}
|
|
</div>);
|
|
|
|
return {
|
|
idle_message: (running_promise
|
|
? <>
|
|
<span>{running_title}</span>
|
|
<Spinner isSVG className="dialog-wait-ct-spinner" size="md" />
|
|
</>
|
|
: null),
|
|
extra_element: extra,
|
|
actions: actions,
|
|
cancel_button: def.Action ? {} : { text: _("Close"), variant: "secondary" }
|
|
};
|
|
};
|
|
|
|
const validate = () => {
|
|
return Promise.all(fields.map(f => {
|
|
if (is_visible(f, values) && f.options && f.options.validate)
|
|
return f.options.validate(values[f.tag], values);
|
|
else
|
|
return null;
|
|
})).then(results => {
|
|
const errors = { };
|
|
fields.forEach((f, i) => { if (results[i]) errors[f.tag] = results[i]; });
|
|
if (Object.keys(errors).length > 0)
|
|
return Promise.reject(errors);
|
|
});
|
|
};
|
|
|
|
const dlg = show_modal_dialog(props(), footer_props(null, null));
|
|
|
|
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();
|
|
},
|
|
|
|
set_nested_values: (key, new_vals) => {
|
|
const updated = values[key];
|
|
Object.assign(updated, new_vals);
|
|
values[key] = updated;
|
|
update();
|
|
},
|
|
|
|
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();
|
|
},
|
|
|
|
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;
|
|
};
|
|
|
|
/* GENERIC FIELD TYPES
|
|
*/
|
|
|
|
export const TextInput = (tag, title, options) => {
|
|
return {
|
|
tag: tag,
|
|
title: title,
|
|
options: 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={change} />
|
|
};
|
|
};
|
|
|
|
export const PassInput = (tag, title, options) => {
|
|
return {
|
|
tag: tag,
|
|
title: title,
|
|
options: options,
|
|
initial_value: options.value || "",
|
|
|
|
render: (val, change, validated) =>
|
|
<TextInputPF4 data-field={tag} data-field-type="text-input"
|
|
validated={validated}
|
|
disabled={options.disabled}
|
|
aria-label={title}
|
|
type="password" value={val}
|
|
onChange={change} />
|
|
};
|
|
};
|
|
|
|
const TypeAheadSelectElement = ({ options, change }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [value, setValue] = useState(options.value);
|
|
|
|
return (
|
|
<TypeAheadSelect
|
|
variant={SelectVariant.typeahead}
|
|
isCreatable
|
|
createText={_("Use")}
|
|
id="nfs-path-on-server"
|
|
isOpen={isOpen}
|
|
selections={value}
|
|
onToggle={isOpen => setIsOpen(isOpen)}
|
|
onSelect={(event, value) => { setValue(value); change(value) }}
|
|
onClear={() => setValue(false)}
|
|
isDisabled={options.disabled}>
|
|
{options.choices.map(entry => <SelectOption key={entry} value={entry} />)}
|
|
</TypeAheadSelect>
|
|
);
|
|
};
|
|
|
|
export const ComboBox = (tag, title, options) => {
|
|
return {
|
|
tag: tag,
|
|
title: title,
|
|
options: options,
|
|
initial_value: options.value || "",
|
|
|
|
render: (val, change, validated) => {
|
|
return <div data-field={tag} data-field-type="combobox">
|
|
<TypeAheadSelectElement options={options} change={change} />
|
|
</div>;
|
|
}
|
|
};
|
|
};
|
|
|
|
export const SelectOne = (tag, title, options) => {
|
|
return {
|
|
tag: tag,
|
|
title: title,
|
|
options: 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={change}>
|
|
{ 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: tag,
|
|
title: title,
|
|
options: options,
|
|
initial_value: options.value || options.choices[0].value,
|
|
hasNoPaddingTop: true,
|
|
|
|
render: (val, change) => {
|
|
return (
|
|
<Split hasGutter data-field={tag} data-field-type="select-radio">
|
|
{ options.choices.map(c => (
|
|
<Radio key={c.value} isChecked={val == c.value} data-data={c.value}
|
|
id={tag + '.' + c.value}
|
|
onChange={event => change(c.value)} label={c.title} />))
|
|
}
|
|
</Split>
|
|
);
|
|
}
|
|
};
|
|
};
|
|
|
|
export const SelectRow = (tag, headers, options) => {
|
|
return {
|
|
tag: tag,
|
|
title: null,
|
|
options: 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: tag,
|
|
title: title,
|
|
options: options,
|
|
initial_value: [],
|
|
|
|
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 = (checked) => {
|
|
if (checked && !selected)
|
|
change(val.concat(spc));
|
|
else if (!checked && selected)
|
|
change(val.filter(v => (v != spc)));
|
|
};
|
|
|
|
return (
|
|
<DataListItem key={spc.block ? spc.block.Device : spc.desc}>
|
|
<DataListItemRow>
|
|
<DataListCheck id={(spc.block ? spc.block.Device : spc.desc) + "-row-checkbox"}
|
|
isChecked={selected} onChange={on_change} />
|
|
<label htmlFor={(spc.block ? spc.block.Device : spc.desc) + "-row-checkbox"}
|
|
className='data-list-row-checkbox-label'>
|
|
<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>,
|
|
]}
|
|
/>
|
|
</label>
|
|
</DataListItemRow>
|
|
</DataListItem>
|
|
);
|
|
})
|
|
}
|
|
</DataList>
|
|
);
|
|
}
|
|
};
|
|
};
|
|
|
|
export const SelectSpace = (tag, title, options) => {
|
|
return {
|
|
tag: tag,
|
|
title: title,
|
|
options: 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);
|
|
};
|
|
|
|
return (
|
|
<DataListItem key={spc.block ? spc.block.Device : spc.desc}>
|
|
<DataListItemRow>
|
|
<div className="pf-c-data-list__item-control">
|
|
<div className="pf-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, update_function }) => {
|
|
return (
|
|
<Checkbox data-field={tag} data-field-type="checkbox"
|
|
id={tag}
|
|
isChecked={val}
|
|
label={
|
|
<>
|
|
{title}
|
|
{ tooltip && <Popover bodyContent={tooltip}>
|
|
<Button className="dialog-item-tooltip" variant="link">
|
|
<HelpIcon />
|
|
</Button>
|
|
</Popover>
|
|
}
|
|
</>
|
|
}
|
|
onChange={update_function} />
|
|
);
|
|
};
|
|
|
|
export const CheckBoxes = (tag, title, options) => {
|
|
return {
|
|
tag: tag,
|
|
title: title,
|
|
options: 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}
|
|
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={checked => update_function(checked ? "" : false)} />
|
|
{val !== false && <TextInputPF4 id={tag + "-input"} value={val} onChange={update_function} />}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const Skip = (className, options) => {
|
|
return {
|
|
tag: false,
|
|
title: null,
|
|
options: options,
|
|
initial_value: false,
|
|
|
|
render: () => {
|
|
return <div className={className} />;
|
|
}
|
|
};
|
|
};
|
|
|
|
export const Message = (text, options) => {
|
|
return {
|
|
options: 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 {
|
|
// Only produce integers by default
|
|
value = Math.round(value);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
class SizeSliderElement extends React.Component {
|
|
constructor(props) {
|
|
super();
|
|
this.units = cockpit.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 = (f) => {
|
|
onChange(Math.max(min, size_slider_round(f * max / 100, round)));
|
|
};
|
|
|
|
const change_text = (value) => {
|
|
/* We keep the literal string as the value and only
|
|
* 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: unit });
|
|
};
|
|
|
|
let slider_val, 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) => this.setState({
|
|
unit: Number(u),
|
|
text: (text_val / this.state.unit) * Number(u)
|
|
});
|
|
|
|
return (
|
|
<Grid hasGutter className="size-slider">
|
|
<GridItem span={12} sm={8}>
|
|
<Slider showBoundaries={false} value={(slider_val / max) * 100} onChange={change_slider} />
|
|
</GridItem>
|
|
<GridItem span={6} sm={2}>
|
|
<TextInputPF4 className="size-text" value={text_val} onChange={change_text} />
|
|
</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) {
|
|
// Convert to number.
|
|
const unit = val.unit;
|
|
|
|
val = Number(val.text) * unit;
|
|
|
|
// As a special case, if the user types something that
|
|
// looks like the maximum (or minimum) when formatted,
|
|
// always use exactly the maximum (or minimum). Otherwise
|
|
// we have the confusing possibility that with the exact
|
|
// same string in the text input, the size is sometimes
|
|
// too large (or too small) and sometimes not.
|
|
|
|
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;
|
|
};
|
|
|
|
/* This object might be mutated by dialog.set_options(), so we
|
|
have to use it below for the 'max' option in order to pick up
|
|
changes to it.
|
|
*/
|
|
const all_options = Object.assign({ }, options, { validate: validate });
|
|
|
|
return {
|
|
tag: tag,
|
|
title: 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 BlockingMessage = (usage) => {
|
|
const usage_desc = {
|
|
pvol: _("physical volume of LVM2 volume group"),
|
|
mdraid: _("member of RAID device"),
|
|
vdo: _("backing device for VDO device"),
|
|
"stratis-pool-member": _("member of Stratis pool")
|
|
};
|
|
|
|
const rows = [];
|
|
usage.forEach(use => {
|
|
if (use.blocking && use.block) {
|
|
const fsys = client.blocks_stratis_fsys[use.block.path];
|
|
const name = (fsys
|
|
? fsys.Devnode
|
|
: block_name(client.blocks[use.block.CryptoBackingDevice] || use.block));
|
|
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
|
|
bodyContent={
|
|
<>
|
|
{ services.length > 0
|
|
? <p>
|
|
<b>{_("Services using the location")}</b>
|
|
<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>
|
|
</p>
|
|
: null
|
|
}
|
|
{ services.length > 0 && processes.length > 0
|
|
? <br />
|
|
: null
|
|
}
|
|
{ processes.length > 0
|
|
? <p>
|
|
<b>{_("Processes using the location")}</b>
|
|
<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>
|
|
</p>
|
|
: null
|
|
}
|
|
</>}>
|
|
<Button variant="link" style={{ visibility: users.length == 0 ? "hidden" : null }}>
|
|
<ExclamationTriangleIcon className="ct-icon-exclamation-triangle" /> { "\n" }
|
|
{_("Currently in use")}
|
|
</Button>
|
|
</Popover>);
|
|
};
|
|
|
|
export const TeardownMessage = (usage) => {
|
|
if (usage.length == 0)
|
|
return null;
|
|
|
|
const rows = [];
|
|
usage.forEach((use, index) => {
|
|
if (use.block) {
|
|
const fsys = client.blocks_stratis_fsys[use.block.path];
|
|
const name = (fsys
|
|
? fsys.Devnode
|
|
: block_name(client.blocks[use.block.CryptoBackingDevice] || use.block));
|
|
rows.push({
|
|
columns: [name,
|
|
use.location || "-",
|
|
use.actions.length ? use.actions.join(", ") : "-",
|
|
{
|
|
title: <UsersPopover users={use.users || []} />,
|
|
props: { className: "ct-text-align-right" }
|
|
}
|
|
]
|
|
});
|
|
}
|
|
});
|
|
|
|
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>);
|
|
};
|
|
|
|
export function init_active_usage_processes(client, usage) {
|
|
return {
|
|
title: _("Checking related processes"),
|
|
func: dlg => {
|
|
return for_each_async(usage, u => {
|
|
if (u.usage == "mounted") {
|
|
return client.find_mount_users(u.location)
|
|
.then(users => {
|
|
u.users = users;
|
|
});
|
|
} else
|
|
return Promise.resolve();
|
|
}).then(() => {
|
|
dlg.set_attribute("Teardown", TeardownMessage(usage));
|
|
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)
|
|
dlg.add_danger(_("Related processes and services will be forcefully stopped."));
|
|
else if (n_processes > 0)
|
|
dlg.add_danger(_("Related processes will be forcefully stopped."));
|
|
else if (n_services > 0)
|
|
dlg.add_danger(_("Related services will be forcefully stopped."));
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
export const StopProcessesMessage = ({ mount_point, users }) => {
|
|
const process_rows = users.filter(u => u.pid).map(u => {
|
|
return {
|
|
columns: [
|
|
u.pid,
|
|
{ title: u.cmd.substr(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.substr(0, 100), props: { modifier: "breakWord" } },
|
|
{ title: u.desc || "", props: { modifier: "breakWord" } },
|
|
{ title: format_delay(-u.since * 1000), props: { modifier: "nowrap" } }
|
|
]
|
|
};
|
|
});
|
|
|
|
// If both tables are shown, we press the columns into a uniform
|
|
// width to reduce the visual mess.
|
|
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>)}
|
|
<ListingTable variant='compact'
|
|
columns={
|
|
[
|
|
{ title: _("PID"), props: colprops },
|
|
{ title: _("Command"), props: colprops },
|
|
{ title: _("User"), props: colprops },
|
|
{ title: _("Runtime"), props: colprops }
|
|
]
|
|
}
|
|
rows={process_rows} />
|
|
</p>
|
|
: 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>)}
|
|
<ListingTable variant='compact'
|
|
columns={
|
|
[
|
|
{ title: _("Service"), props: colprops },
|
|
{ title: _("Command"), props: colprops },
|
|
{ title: _("Description"), props: colprops },
|
|
{ title: _("Runtime"), props: colprops }
|
|
]
|
|
}
|
|
rows={service_rows} />
|
|
</p>
|
|
: 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;
|
|
};
|