cockpit/pkg/storaged/stratis-details.jsx

798 lines
31 KiB
JavaScript

/*
* This file is part of Cockpit.
*
* Copyright (C) 2021 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/>.
*/
import cockpit from "cockpit";
import React from "react";
import {
Card, CardBody, CardTitle, CardHeader, CardActions, Text, TextVariants,
DescriptionList, DescriptionListTerm, DescriptionListGroup, DescriptionListDescription,
List, ListItem
} from "@patternfly/react-core";
import { PlusIcon, ExclamationTriangleIcon } from "@patternfly/react-icons";
import { FilesystemTab, mounting_dialog, is_mounted, is_valid_mount_point, get_fstab_config } from "./fsys-tab.jsx";
import { ListingTable } from "cockpit-components-table.jsx";
import { ListingPanel } from 'cockpit-components-listing-panel.jsx';
import { StdDetailsLayout } from "./details.jsx";
import { StorageButton, StorageBarMenu, StorageMenuItem, StorageUsageBar } from "./storage-controls.jsx";
import { SidePanel } from "./side-panel.jsx";
import {
dialog_open,
TextInput, PassInput, SelectOne, SelectSpaces,
CheckBoxes,
BlockingMessage, TeardownMessage,
init_active_usage_processes
} from "./dialog.jsx";
import {
fmt_size,
encode_filename, decode_filename,
get_active_usage, teardown_active_usage,
get_available_spaces, prepare_available_spaces,
reload_systemd, for_each_async
} from "./utils.js";
import { fmt_to_fragments } from "utils.jsx";
import { never_auto_explanation } from "./format-dialog.jsx";
const _ = cockpit.gettext;
function teardown_block(block) {
return for_each_async(block.Configuration, c => block.RemoveConfigurationItem(c, {}));
}
function destroy_filesystem(client, fsys) {
const block = client.slashdevs_block[fsys.Devnode];
const pool = client.stratis_pools[fsys.Pool];
return teardown_block(block)
.then(() => {
return pool.call("DestroyFilesystems", [[fsys.path]])
.then(([result, code, message]) => {
if (code)
return Promise.reject(message);
});
});
}
function destroy_pool(client, pool) {
return for_each_async(client.stratis_pool_filesystems[pool.path], fsys => destroy_filesystem(client, fsys))
.then(() => {
return client.stratis_manager.call("DestroyPool", [pool.path])
.then(([result, code, message]) => {
if (code)
return Promise.reject(message);
});
});
}
function remove_passphrase(client, key_desc) {
return client.stratis_manager.UnsetKey(key_desc)
.then((result, code, message) => {
if (code)
return Promise.reject(message);
})
.catch(ex => {
console.warn("Failed to remove passphrase from key ring", ex.toString());
});
}
const StratisPoolSidebar = ({ client, pool }) => {
const blockdevs = client.stratis_pool_blockdevs[pool.path] || [];
function add_disks() {
if (!pool.Encrypted ||
!pool.KeyDescription ||
!pool.KeyDescription[0] ||
!pool.KeyDescription[1][0]) {
add_disks_with_keydesc(false);
return;
}
const key_desc = pool.KeyDescription[1][1];
return client.stratis_list_keys()
.then(keys => {
if (keys.indexOf(key_desc) >= 0)
add_disks_with_keydesc(false);
else
add_disks_with_keydesc(key_desc);
})
.catch(ex => {
console.warn("Failed fetch properties", ex.toString());
});
}
function add_disks_with_keydesc(key_desc) {
dialog_open({
Title: _("Add block devices"),
Fields: [
SelectOne("tier", _("Tier"),
{
choices: [
{ value: "data", title: _("Data") },
{ value: "cache", title: _("Cache"), disabled: pool.Encrypted }
]
}),
PassInput("passphrase", _("Passphrase"),
{
visible: () => !!key_desc,
validate: val => !val.length && _("Passphrase cannot be empty"),
}),
SelectSpaces("disks", _("Block devices"),
{
empty_warning: _("No disks are available."),
validate: function(disks) {
if (disks.length === 0)
return _("At least one disk is needed.");
},
spaces: get_available_spaces(client)
})
],
Action: {
Title: _("Add"),
action: function(vals) {
return prepare_available_spaces(client, vals.disks)
.then(paths => {
const devs = paths.map(p => decode_filename(client.blocks[p].PreferredDevice));
function add() {
if (vals.tier == "data") {
return pool.call("AddDataDevs", [devs])
.then(([result, code, message]) => {
if (code)
return Promise.reject(message);
});
} else if (vals.tier == "cache") {
const has_cache = blockdevs.some(bd => bd.Tier == 1);
const method = has_cache ? "AddCacheDevs" : "InitCache";
return pool.call(method, [devs])
.then(([result, code, message]) => {
if (code)
return Promise.reject(message);
});
}
}
if (key_desc) {
return client.stratis_store_passphrase(key_desc, vals.passphrase)
.then(add)
.catch(ex => {
return remove_passphrase(client, key_desc)
.then(() => Promise.reject(ex));
})
.then(() => {
return remove_passphrase(client, key_desc);
});
} else
return add();
});
}
}
});
}
function render_blockdev(blockdev) {
const block = client.slashdevs_block[blockdev.PhysicalPath];
let desc;
if (!block)
return null;
if (blockdev.Tier == 0)
desc = cockpit.format(_("$0 data"),
fmt_size(Number(blockdev.TotalPhysicalSize)));
else if (blockdev.Tier == 1)
desc = cockpit.format(_("$0 cache"),
fmt_size(Number(blockdev.TotalPhysicalSize)));
else
desc = cockpit.format(_("$0 of unknown tier"),
fmt_size(Number(blockdev.TotalPhysicalSize)));
return { client, block, detail: desc, key: blockdev.path };
}
const actions = (
<StorageButton onClick={add_disks}>
<PlusIcon />
</StorageButton>);
return (
<SidePanel title={_("Block devices")}
actions={actions}
client={client}
rows={blockdevs.map(render_blockdev)} />
);
};
export function validate_pool_name(client, pool, name) {
if (name == "")
return _("Name can not be empty.");
if ((!pool || name != pool.Name) && client.stratis_poolnames_pool[name])
return _("A pool with this name exists already.");
}
export const StratisPoolDetails = ({ client, pool }) => {
const filesystems = client.stratis_pool_filesystems[pool.path];
const forced_options = ["x-systemd.requires=stratis-fstab-setup@" + pool.Uuid + ".service"];
function delete_() {
const location = cockpit.location;
const usage = get_active_usage(client, pool.path, _("delete"));
if (usage.Blocking) {
dialog_open({
Title: cockpit.format(_("$0 is in use"),
pool.Name),
Body: BlockingMessage(usage)
});
return;
}
dialog_open({
Title: cockpit.format(_("Permanently delete $0?"), pool.Name),
Teardown: TeardownMessage(usage),
Action: {
Danger: _("Deleting a Stratis pool will erase all data it contains."),
Title: _("Delete"),
action: function () {
return teardown_active_usage(client, usage)
.then(() => destroy_pool(client, pool))
.then(() => {
location.go('/');
});
}
},
Inits: [
init_active_usage_processes(client, usage)
]
});
}
function rename() {
dialog_open({
Title: _("Rename Stratis pool"),
Fields: [
TextInput("name", _("Name"),
{
value: pool.Name,
validate: name => validate_pool_name(client, pool, name)
})
],
Action: {
Title: _("Rename"),
action: function (vals) {
return pool.SetName(vals.name)
.then((result, code, message) => {
if (code)
return Promise.reject(message);
});
}
}
});
}
function set_mount_options(path, vals) {
let mount_options = [];
if (!vals.mount_options.auto || vals.mount_options.never_auto)
mount_options.push("noauto");
if (vals.mount_options.ro)
mount_options.push("ro");
if (vals.mount_options.never_auto)
mount_options.push("x-cockpit-never-auto");
if (vals.mount_options.extra)
mount_options.push(vals.mount_options.extra);
mount_options = mount_options.concat(forced_options);
let mount_point = vals.mount_point;
if (mount_point[0] != "/")
mount_point = "/" + mount_point;
const config =
["fstab",
{
dir: { t: 'ay', v: encode_filename(mount_point) },
type: { t: 'ay', v: encode_filename("auto") },
opts: { t: 'ay', v: encode_filename(mount_options.join(",") || "defaults") },
freq: { t: 'i', v: 0 },
passno: { t: 'i', v: 0 },
}
];
function udisks_block_for_stratis_fsys() {
const fsys = client.stratis_filesystems[path];
return fsys && client.slashdevs_block[fsys.Devnode];
}
return client.wait_for(udisks_block_for_stratis_fsys)
.then(block => {
// HACK - need a explicit "change" event
return block.Rescan({})
.then(() => {
return client.wait_for(() => client.blocks_fsys[block.path])
.then(fsys => {
return block.AddConfigurationItem(config, {})
.then(reload_systemd)
.then(() => {
if (vals.mount_options.auto)
return client.mount_at(block, mount_point);
else
return Promise.resolve();
});
});
});
});
}
function validate_fs_name(fsys, name) {
if (name == "")
return _("Name can not be empty.");
if (!fsys || name != fsys.Name) {
for (const fs of filesystems) {
if (fs.Name == name)
return _("A filesystem with this name exists already in this pool.");
}
}
}
function create_fs() {
dialog_open({
Title: _("Create filesystem"),
Fields: [
TextInput("name", _("Name"),
{
validate: name => validate_fs_name(null, name)
}),
TextInput("mount_point", _("Mount point"),
{
validate: val => is_valid_mount_point(client, null, val)
}),
CheckBoxes("mount_options", _("Mount options"),
{
value: {
auto: true,
ro: false,
never_auto: false,
extra: false
},
fields: [
{ title: _("Mount now"), tag: "auto" },
{ title: _("Mount read only"), tag: "ro" },
{
title: _("Never mount at boot"),
tag: "never_auto",
tooltip: never_auto_explanation,
},
{ title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" },
]
}),
],
Action: {
Title: _("Create"),
action: function (vals) {
return client.stratis_create_filesystem(pool, vals.name)
.then((result, code, message) => {
if (code)
return Promise.reject(message);
if (result[0])
return set_mount_options(result[1][0][0], vals);
else
return Promise.resolve();
});
}
}
});
}
const use = pool.TotalPhysicalUsed[0] && [Number(pool.TotalPhysicalUsed[1]), Number(pool.TotalPhysicalSize)];
const header = (
<Card>
<CardHeader>
<CardTitle>
<Text component={TextVariants.h2}>
{fmt_to_fragments((pool.Encrypted ? _("Encrypted Stratis pool $0") : _("Stratis pool $0")), <b>{pool.Name}</b>)}
</Text>
</CardTitle>
<CardActions>
<StorageButton onClick={rename}>{_("Rename")}</StorageButton>
<StorageButton kind="danger" onClick={delete_}>{_("Delete")}</StorageButton>
</CardActions>
</CardHeader>
<CardBody>
<DescriptionList className="pf-m-horizontal-on-sm">
<DescriptionListGroup>
<DescriptionListTerm className="control-DescriptionListTerm">{_("storage", "UUID")}</DescriptionListTerm>
<DescriptionListDescription>{ pool.Uuid }</DescriptionListDescription>
</DescriptionListGroup>
{ use &&
<DescriptionListGroup>
<DescriptionListTerm className="control-DescriptionListTerm">{_("storage", "Usage")}</DescriptionListTerm>
<DescriptionListDescription className="pf-u-align-self-center">
<StorageUsageBar stats={use} critical={0.95} />
</DescriptionListDescription>
</DescriptionListGroup>
}
</DescriptionList>
</CardBody>
</Card>
);
const sidebar = <StratisPoolSidebar client={client} pool={pool} />;
function render_fsys(fsys, offset, total) {
const block = client.slashdevs_block[fsys.Devnode];
if (!block) {
return {
props: { key: fsys.Name },
columns: [{ title: fsys.Name }]
};
}
const [, mount_point] = get_fstab_config(block);
const fs_is_mounted = is_mounted(client, block);
function mount() {
return mounting_dialog(client, block, "mount", forced_options);
}
function unmount() {
return mounting_dialog(client, block, "unmount", forced_options);
}
function rename_fsys() {
dialog_open({
Title: _("Rename filesystem"),
Fields: [
TextInput("name", _("Name"),
{
value: fsys.Name,
validate: name => validate_fs_name(fsys, name)
})
],
Action: {
Title: _("Rename"),
action: function (vals) {
return fsys.SetName(vals.name)
.then((result, code, message) => {
if (code)
return Promise.reject(message);
});
}
}
});
}
function snapshot_fsys() {
dialog_open({
Title: cockpit.format(_("Create a snapshot of filesystem $0"), fsys.Name),
Fields: [
TextInput("name", _("Name"),
{
value: "",
validate: name => validate_fs_name(null, name)
}),
TextInput("mount_point", _("Mount point"),
{
validate: val => is_valid_mount_point(client, null, val)
}),
CheckBoxes("mount_options", _("Mount options"),
{
value: {
auto: true,
ro: false,
never_auto: false,
extra: false
},
fields: [
{ title: _("Mount now"), tag: "auto" },
{ title: _("Mount read only"), tag: "ro" },
{
title: _("Never mount at boot"),
tag: "never_auto",
tooltip: never_auto_explanation,
},
{ title: _("Custom mount options"), tag: "extra", type: "checkboxWithInput" },
]
})
],
Action: {
Title: _("Create snapshot"),
action: function (vals) {
return pool.SnapshotFilesystem(fsys.path, vals.name)
.then((result, code, message) => {
if (code)
return Promise.reject(message);
if (result[0])
return set_mount_options(result[1], vals);
else
return Promise.resolve();
});
}
}
});
}
function delete_fsys() {
const usage = get_active_usage(client, block.path, _("delete"));
if (usage.Blocking) {
dialog_open({
Title: cockpit.format(_("$0 is in use"),
fsys.Name),
Body: BlockingMessage(usage)
});
return;
}
dialog_open({
Title: cockpit.format(_("Confirm deletion of $0"), fsys.Name),
Teardown: TeardownMessage(usage),
Action: {
Danger: _("Deleting a filesystem will delete all data in it."),
Title: _("Delete"),
action: function () {
return teardown_active_usage(client, usage)
.then(() => destroy_filesystem(client, fsys));
}
},
Inits: [
init_active_usage_processes(client, usage)
]
});
}
const associated_warnings = ["mismounted-fsys"];
const warnings = client.path_warnings[block.path] || [];
const tab_warnings = warnings.filter(w => associated_warnings.indexOf(w.warning) >= 0);
const name = _("Filesystem");
let info = null;
if (tab_warnings.length > 0)
info = <>{info}<ExclamationTriangleIcon className="ct-icon-exclamation-triangle" /></>;
if (info)
info = <>{"\n"}{info}</>;
const tabs = [
{
name: name,
renderer: FilesystemTab,
data: {
client: client,
block: block,
warnings: tab_warnings,
forced_options: forced_options
}
}
];
const actions = [];
const menuitems = [];
if (!fs_is_mounted) {
actions.push(<StorageButton onlyWide key="mount" onClick={mount}>{_("Mount")}</StorageButton>);
menuitems.push(<StorageMenuItem onlyNarrow key="mount" onClick={mount}>{_("Mount")}</StorageMenuItem>);
}
if (fs_is_mounted)
menuitems.push(<StorageMenuItem key="unmount" onClick={unmount}>{_("Unmount")}</StorageMenuItem>);
menuitems.push(<StorageMenuItem key="rename" onClick={rename_fsys}>{_("Rename")}</StorageMenuItem>);
menuitems.push(<StorageMenuItem key="snapshot" onClick={snapshot_fsys}>{_("Snapshot")}</StorageMenuItem>);
menuitems.push(<StorageMenuItem key="del" onClick={delete_fsys}>{_("Delete")}</StorageMenuItem>);
const cols = [
{
title: (
<span>
{fsys.Name}
{info}
</span>)
},
{
title: mount_point
},
{
title: <StorageUsageBar stats={[Number(fsys.Used[0] && Number(fsys.Used[1])),
Number(pool.TotalPhysicalSize)]}
critical={1} total={total} offset={offset} />,
props: { className: "ct-text-align-right" }
},
{
title: <>{actions}<StorageBarMenu key="menu" menuItems={menuitems} isKebab /></>,
props: { className: "pf-c-table__action content-action" }
}
];
return {
props: { key: fsys.Name },
columns: cols,
expandedContent: <ListingPanel tabRenderers={tabs} />
};
}
const offsets = [];
let total = 0;
filesystems.forEach(fs => {
offsets.push(total);
total += fs.Used[0] ? Number(fs.Used[1]) : 0;
});
const rows = filesystems.map((fs, i) => render_fsys(fs, offsets[i], total));
const content = (
<Card>
<CardHeader>
<CardTitle><Text component={TextVariants.h2}>{_("Filesystems")}</Text></CardTitle>
<CardActions>
<StorageButton onClick={create_fs}>{_("Create new filesystem")}</StorageButton>
</CardActions>
</CardHeader>
<CardBody className="contains-list">
<ListingTable emptyCaption={_("No filesystems")}
aria-label={_("Filesystems")}
columns={[_("Name"), _("Used for"), _("Size")]}
showHeader={false}
rows={rows.filter(row => !!row)} />
</CardBody>
</Card>);
return <StdDetailsLayout client={client}
header={header}
sidebar={sidebar}
content={content} />;
};
export function unlock_pool(client, uuid, show_devs) {
const manager = client.stratis_manager;
const locked_props = manager.LockedPools[uuid];
const devs = locked_props.devs.v.map(d => d.devnode).sort();
if (!locked_props.key_description ||
locked_props.key_description.t != "(bv)" ||
!locked_props.key_description.v[0] ||
locked_props.key_description.v[1].t != "(bs)" ||
!locked_props.key_description.v[1].v[0]) {
dialog_open({
Title: _("Error"),
Body: _("This pool can not be unlocked here because its key description is not in the expected format.")
});
return;
}
const key_desc = locked_props.key_description.v[1].v[1];
function unlock() {
return client.stratis_unlock_pool(uuid)
.then((result, code, message) => {
if (code)
return Promise.reject(message);
});
}
function unlock_with_keydesc(key_desc) {
dialog_open({
Title: _("Unlock encrypted Stratis pool"),
Body: (show_devs &&
<>
<p>{_("Provide the passphrase for the pool on these block devices:")}</p>
<List>{devs.map(d => <ListItem key={d}>{d}</ListItem>)}</List>
<br />
</>),
Fields: [
PassInput("passphrase", _("Passphrase"), { })
],
Action: {
Title: _("Unlock"),
action: function(vals) {
return client.stratis_store_passphrase(key_desc, vals.passphrase)
.then(unlock)
.catch(ex => {
return remove_passphrase(client, key_desc)
.then(() => Promise.reject(ex));
})
.then(() => {
return remove_passphrase(client, key_desc);
});
}
}
});
}
return manager.client.call(manager.path, "org.storage.stratis2.FetchProperties.r2", "GetProperties", [["KeyList"]])
.catch(() => [{ }])
.then(([result]) => {
let keys = [];
if (result.KeyList && result.KeyList[0])
keys = result.KeyList[1].v;
if (keys.indexOf(key_desc) >= 0)
return unlock();
else
unlock_with_keydesc(key_desc);
});
}
const StratisLockedPoolSidebar = ({ client, uuid }) => {
const locked_props = client.stratis_manager.LockedPools[uuid];
const devs = locked_props.devs.v.map(d => d.devnode).sort();
function render_dev(dev) {
const block = client.slashdevs_block[dev];
if (!block)
return null;
return { client, block, key: dev };
}
return (
<SidePanel title={_("Block devices")}
client={client}
rows={devs.map(render_dev)} />
);
};
export const StratisLockedPoolDetails = ({ client, uuid }) => {
function unlock() {
return unlock_pool(client, uuid);
}
const header = (
<Card>
<CardHeader>
<CardTitle><Text component={TextVariants.h2}>{_("Locked encrypted Stratis pool")}</Text></CardTitle>
<CardActions>
<StorageButton kind="primary" onClick={unlock}>{_("Unlock")}</StorageButton>
</CardActions>
</CardHeader>
<CardBody>
<DescriptionList className="pf-m-horizontal-on-sm">
<DescriptionListGroup>
<DescriptionListTerm>{_("storage", "UUID")}</DescriptionListTerm>
<DescriptionListDescription>{ uuid }</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</CardBody>
</Card>
);
const content = (
<Card>
<CardHeader>
<CardTitle><Text component={TextVariants.h2}>{_("Filesystems")}</Text></CardTitle>
</CardHeader>
<CardBody className="contains-list">
<ListingTable emptyCaption={_("Unlock pool to see filesystems.")}
aria-label={_("Filesystems")}
columns={[_("Name"), _("Used for"), _("Size")]}
showHeader={false}
rows={[]} />
</CardBody>
</Card>);
return <StdDetailsLayout client={client}
header={header}
sidebar={<StratisLockedPoolSidebar client={client} uuid={uuid} />}
content={content} />;
};