mirror-cockpit-project-cockpit/pkg/kdump/kdump-view.jsx
2026-10-16 17:49:20 +02:00

668 lines
31 KiB
JavaScript

/*
* Copyright (C) 2016 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import '../lib/patternfly/patternfly-6-cockpit.scss';
import cockpit from "cockpit";
import React, { useEffect, useState } from "react";
import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/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 { Card, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js";
import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { Form, FormGroup, FormSection } from "@patternfly/react-core/dist/esm/components/Form/index.js";
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
import { CodeBlockCode } from "@patternfly/react-core/dist/esm/components/CodeBlock/index.js";
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
import {
Modal, ModalBody, ModalFooter, ModalHeader
} from '@patternfly/react-core/dist/esm/components/Modal/index.js';
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
import { Title } from "@patternfly/react-core/dist/esm/components/Title/index.js";
import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
import { useDialogs, DialogsContext } from "dialogs.jsx";
import { read_os_release } from "os-release.js";
import { fmt_to_fragments } from 'utils.jsx';
import { FormHelper } from "cockpit-components-form-helper";
import { ModalError } from 'cockpit-components-inline-notification.jsx';
import { PrivilegedButton } from "cockpit-components-privileged";
import { ModificationsExportDialog } from "cockpit-components-modifications";
const _ = cockpit.gettext;
const DEFAULT_KDUMP_PATH = "/var/crash";
const exportAnsibleTask = (settings, os_release) => {
const target = Object.keys(settings.targets)[0];
const targetSettings = settings.targets[target];
const kdump_core_collector = settings.core_collector;
let role_name = "linux-system-roles";
if (os_release?.PLATFORM_ID?.startsWith('platform:el') || os_release.ID_LIKE?.includes('rhel')) {
role_name = "rhel-system-roles";
}
let ansible = `
---
# Also available via https://galaxy.ansible.com/ui/standalone/roles/linux-system-roles/kdump/
- name: install ${role_name}
package:
name: ${role_name}
state: present
delegate_to: 127.0.0.1
become: true
- name: run kdump system role
include_role:
name: ${role_name}.kdump
vars:
kdump_path: ${targetSettings.path || DEFAULT_KDUMP_PATH}
kdump_core_collector: ${kdump_core_collector}`;
if (target === "ssh") {
// HACK: we should not have to specify kdump_ssh_user and kdump_ssh_user as it is in kdump_target.location
// https://github.com/linux-system-roles/kdump/issues/184
let ssh_user;
let ssh_server;
const parts = targetSettings.server.split('@');
if (parts.length === 1) {
ssh_user = "root";
ssh_server = parts[0];
} else if (parts.length === 2) {
ssh_user = parts[0];
ssh_server = parts[1];
} else {
throw new Error("ssh server contains two @ symbols");
}
ansible += `
kdump_target:
type: ssh
kdump_sshkey: ${targetSettings.sshkey}
kdump_ssh_server: ${ssh_server}
kdump_ssh_user: ${ssh_user}`;
} else if (target === "nfs") {
ansible += `
kdump_target:
type: nfs
location: ${targetSettings.server}:${targetSettings.export}
`;
} else if (target !== "local") {
// target is unsupported
throw new Error("Unsupported kdump target"); // not-covered: assertion
}
return ansible;
};
function getLocation(target, config) {
let path = target.path || DEFAULT_KDUMP_PATH;
if (target.type === "ssh") {
path = `${target.server}:${path}`;
} else if (target.type === "nfs") {
if (!config.nfs_supports_directory) {
path = '';
}
path = path[0] !== '/' ? '/' + path : path;
path = `${target.server}:${target.export + path}`;
}
return path;
}
const KdumpSettingsModal = ({ settings, initialTarget, handleSave }) => {
const Dialogs = useDialogs();
const compressionAllowed = settings.compression?.allowed;
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
const [isFormValid, setFormValid] = useState(true);
const [validationErrors, setValidationErrors] = useState({});
const [storageLocation, setStorageLocation] = useState(Object.keys(settings.targets)[0]);
// common options
const [compressionEnabled, setCompressionEnabled] = useState(settings.compression?.enabled);
const [directory, setDirectory] = useState(initialTarget.path || DEFAULT_KDUMP_PATH);
// nfs and ssh
const [server, setServer] = useState(settings.targets.nfs?.server || settings.targets.ssh?.server);
// nfs
const [exportPath, setExportPath] = useState(settings.targets.nfs?.export || "");
// ssh
const [sshkey, setSSHKey] = useState(settings.targets.ssh?.sshkey || "");
useEffect(() => {
// We can't use a ref in a functional component
const elem = document.querySelector("#kdump-settings-form");
if (elem)
setFormValid(elem.checkValidity());
}, [storageLocation, directory, sshkey, server, exportPath]);
const changeStorageLocation = target => {
setError(null);
setDirectory(DEFAULT_KDUMP_PATH);
setServer("");
setStorageLocation(target);
};
const changeSSHKey = value => {
if (value.trim() && !value.match("/.+")) {
setValidationErrors({ sshkey: _("SSH key isn't a path") });
} else {
setValidationErrors({});
}
setSSHKey(value);
};
const saveSettings = () => {
setError(null);
setIsSaving(true);
const newSettings = {
compression: {
allowed: compressionAllowed,
enabled: compressionEnabled,
},
targets: {
[storageLocation]: {
type: storageLocation,
// HACK: to not needlessly write a path /var/crash as this is the default,
// set an empty string.
path: directory === DEFAULT_KDUMP_PATH ? "" : directory,
}
},
_internal: {
...settings._internal
}
};
if (storageLocation === "ssh") {
newSettings.targets.ssh.server = server;
newSettings.targets.ssh.sshkey = sshkey;
}
if (storageLocation === "nfs") {
newSettings.targets.nfs.server = server;
newSettings.targets.nfs.export = exportPath;
}
handleSave(newSettings)
.then(Dialogs.close)
.finally(() => setIsSaving(false))
.catch(error => {
if (error.details) {
// avoid bad summary like "systemd job RestartUnit ["kdump.service","replace"] failed with result failed"
// if we have a more concrete journal and trim journal's `kdump: ` prefix.
error.message = _("Unable to save settings");
error.details = <CodeBlockCode>{ error.details.replaceAll(/\nkdump: /g, "\n") }</CodeBlockCode>;
setError(error);
} else {
// without a journal, show the error as-is
setError(new Error(cockpit.format(_("Unable to save settings: $0"), String(error))));
}
});
};
return (
<Modal position="top" variant="small" id="kdump-settings-dialog" isOpen
onClose={Dialogs.close}>
<ModalHeader title={_("Crash dump location")} />
<ModalBody>
{error && <ModalError isExpandable
dialogError={error.message || error}
dialogErrorDetail={error.details} />}
<Form id="kdump-settings-form" isHorizontal>
<FormGroup fieldId="kdump-settings-location" label={_("Location")}>
<FormSelect key="location" onChange={(_, val) => changeStorageLocation(val)}
id="kdump-settings-location" value={storageLocation}>
<FormSelectOption value='local'
label={_("Local filesystem")} />
<FormSelectOption value='ssh'
label={_("Remote over SSH")} />
<FormSelectOption value='nfs'
label={_("Remote over NFS")} />
</FormSelect>
</FormGroup>
{storageLocation === "local" &&
<FormGroup fieldId="kdump-settings-local-directory" label={_("Directory")} isRequired>
<TextInput id="kdump-settings-local-directory" key="directory"
placeholder={DEFAULT_KDUMP_PATH} value={directory}
data-stored={directory}
onChange={(_event, value) => setDirectory(value)}
isRequired />
</FormGroup>
}
{storageLocation === "nfs" &&
<>
<FormGroup fieldId="kdump-settings-nfs-server" label={_("Server")} isRequired>
<TextInput id="kdump-settings-nfs-server" key="server"
placeholder="penguin.example.com" value={server}
onChange={(_event, value) => setServer(value)} isRequired />
</FormGroup>
<FormGroup fieldId="kdump-settings-nfs-export" label={_("Export")} isRequired>
<TextInput id="kdump-settings-nfs-export" key="export"
placeholder="/export/cores" value={exportPath}
onChange={(_event, value) => setExportPath(value)} isRequired />
</FormGroup>
{settings.nfs_supports_directory &&
<FormGroup fieldId="kdump-settings-nfs-directory" label={_("Directory")} isRequired>
<TextInput id="kdump-settings-nfs-directory" key="directory"
placeholder={DEFAULT_KDUMP_PATH} value={directory}
data-stored={directory}
onChange={(_event, value) => setDirectory(value)}
isRequired />
</FormGroup>
}
</>
}
{storageLocation === "ssh" &&
<>
<FormGroup fieldId="kdump-settings-ssh-server" label={_("Server")} isRequired>
<TextInput id="kdump-settings-ssh-server" key="server"
placeholder="user@server.com" value={server}
onChange={(_event, value) => setServer(value)} isRequired />
</FormGroup>
<FormGroup fieldId="kdump-settings-ssh-key" label={_("SSH key")}>
<TextInput id="kdump-settings-ssh-key" key="ssh"
placeholder="/root/.ssh/kdump_id_rsa" value={sshkey}
onChange={(_event, value) => changeSSHKey(value)}
validated={validationErrors.sshkey ? "error" : "default"} />
<FormHelper helperTextInvalid={validationErrors.sshkey} />
</FormGroup>
<FormGroup fieldId="kdump-settings-ssh-directory" label={_("Directory")} isRequired>
<TextInput id="kdump-settings-ssh-directory" key="directory"
placeholder={DEFAULT_KDUMP_PATH} value={directory}
data-stored={directory}
onChange={(_event, value) => setDirectory(value)}
isRequired />
</FormGroup>
</>
}
<FormSection>
<FormGroup fieldId="kdump-settings-compression" label={_("Compression")} hasNoPaddingTop>
<Checkbox id="kdump-settings-compression"
isChecked={compressionEnabled}
onChange={(_, c) => setCompressionEnabled(c)}
isDisabled={!compressionAllowed}
label={_("Compress crash dumps to save space")} />
</FormGroup>
</FormSection>
</Form>
</ModalBody>
<ModalFooter>
<Button variant="primary"
isLoading={isSaving}
isDisabled={isSaving || !isFormValid || Object.keys(validationErrors).length !== 0}
onClick={saveSettings}>
{_("Save changes")}
</Button>
<Button variant="link"
isDisabled={isSaving}
className="cancel"
onClick={Dialogs.close}>
{_("Cancel")}
</Button>
</ModalFooter>
</Modal>);
};
const KdumpTestDialog = ({ verifyMessage, onCrashKernel }) => {
const Dialogs = useDialogs();
const [task, setTask] = useState(null);
const [error, setError] = useState(null);
function crash() {
setError(null);
setTask(onCrashKernel()
.then(Dialogs.close)
.catch(error => {
setTask(null);
setError(error);
}));
}
return (
<Modal position="top" variant="small" id="kdump-test-dialog" isOpen
onClose={Dialogs.close}>
<ModalHeader title={_("Test kdump settings")} titleIconVariant="warning" />
<ModalBody>
{error && <ModalError dialogError={error.message || error} />}
<Content>
<Content component={ContentVariants.p}>
{_("Test kdump settings by crashing the kernel. This may take a while and the system might not automatically reboot. Do not purposefully crash the system while any important task is running.")}
</Content>
{verifyMessage && <Content component={ContentVariants.p}>
{verifyMessage}
</Content>}
</Content>
</ModalBody>
<ModalFooter>
<Button variant="danger"
isLoading={!!task}
isDisabled={!!task}
onClick={crash}>
{_("Crash system")}
</Button>
<Button variant="link"
isDisabled={!!task}
onClick={Dialogs.close}>
{_("Cancel")}
</Button>
</ModalFooter>
</Modal>);
};
/* Show kdump status of the system and offer options to change or test the state
* Expected properties:
* kdumpActive kdump service status
* onSetServiceState called when the OnOff state is toggled (for kdumpActive), parameter: desired state
* stateChanging whether we're currently waiting for our last change to take effect
* onSaveSettings called with current dialog settings when the user clicks Save
* kdumpStatus object as described in kdump-client
* reservedMemory memory reserved at boot time for kdump use
* onCrashKernel callback to crash the kernel via kdumpClient, expects a promise
*/
export class KdumpPage extends React.Component {
static contextType = DialogsContext;
constructor(props) {
super(props);
this.state = { os_release: null };
this.handleTestSettingsClick = this.handleTestSettingsClick.bind(this);
this.handleSettingsClick = this.handleSettingsClick.bind(this);
this.handleAutomationClick = this.handleAutomationClick.bind(this);
read_os_release().then(os_release => this.setState({ os_release }));
}
handleTestSettingsClick() {
const Dialogs = this.context;
// if we have multiple targets defined, the config is invalid
const target = this.props.kdumpStatus.target;
let verifyMessage;
if (!target.multipleTargets) {
const path = getLocation(target, this.props.kdumpStatus.config);
if (target.type === "local") {
verifyMessage = fmt_to_fragments(
' ' + _("Results of the crash will be stored in $0 as $1, if kdump is properly configured."),
<span className="pf-v6-u-font-family-monospace-vf">{path}</span>,
<span className="pf-v6-u-font-family-monospace-vf">vmcore</span>);
} else if (target.type === "ssh" || target.type === "nfs") {
verifyMessage = fmt_to_fragments(
' ' + _("Results of the crash will be copied through $0 to $1 as $2, if kdump is properly configured."),
<span className="pf-v6-u-font-family-monospace-vf">{target.type === "ssh" ? "SSH" : "NFS"}</span>,
<span className="pf-v6-u-font-family-monospace-vf">{path}</span>,
<span className="pf-v6-u-font-family-monospace-vf">vmcore</span>);
}
}
Dialogs.show(<KdumpTestDialog verifyMessage={verifyMessage}
onCrashKernel={this.props.onCrashKernel} />);
}
handleServiceDetailsClick() {
cockpit.jump("/system/services#/kdump.service", cockpit.transport.host);
}
handleSettingsClick() {
const Dialogs = this.context;
Dialogs.show(<KdumpSettingsModal settings={this.props.kdumpStatus.config}
initialTarget={this.props.kdumpStatus.target}
handleSave={this.props.onSaveSettings} />);
}
handleAutomationClick() {
const Dialogs = this.context;
let enableCrashKernel = '';
let kdumpconf = this.props.exportConfig(this.props.kdumpStatus.config);
kdumpconf = kdumpconf.replaceAll('$', '\\$');
if (this.state.os_release.NAME?.includes('Fedora')) {
enableCrashKernel = `
# A reboot will be required if crashkernel was not set before
kdumpctl reset-crashkernel`;
}
let shell;
if (this.state.os_release.NAME?.includes('MicroOS')) {
enableCrashKernel = `
# A reboot will be required if crashkernel was not set before
transactional-update setup-kdump`;
shell = `
cat > /etc/kdump.conf << EOF
${kdumpconf}
EOF
${enableCrashKernel}
`;
} else {
shell = `
cat > /etc/kdump.conf << EOF
${kdumpconf}
EOF
systemctl enable --now kdump.service
${enableCrashKernel}
`;
}
Dialogs.show(
<ModificationsExportDialog
ansible={ this.state.os_release.NAME?.includes('MicroOS') ? null : exportAnsibleTask(this.props.kdumpStatus.config, this.state.os_release)}
shell={shell}
onClose={Dialogs.close}
/>);
}
render() {
let kdumpLocation = (
<div className="dialog-wait-ct">
<Spinner size="md" />
<span>{ _("Loading...") }</span>
</div>
);
let targetCanChange = false;
if (this.props.kdumpStatus && this.props.kdumpStatus.target) {
// if we have multiple targets defined, the config is invalid
const target = this.props.kdumpStatus.target;
if (target.multipleTargets) {
kdumpLocation = _("invalid: multiple targets defined");
} else {
const locationPath = getLocation(target, this.props.kdumpStatus.config);
if (target.type === "local") {
kdumpLocation = cockpit.format(_("Local, $0"), locationPath);
targetCanChange = true;
} else if (target.type === "ssh") {
kdumpLocation = cockpit.format(_("Remote over SSH, $0"), locationPath);
targetCanChange = true;
} else if (target.type === "nfs") {
kdumpLocation = cockpit.format(_("Remote over NFS, $0"), locationPath);
targetCanChange = true;
} else if (target.type === "raw") {
kdumpLocation = _("Raw to a device");
} else if (target.type === "mount") {
/* mount targets outside of nfs are too complex for the
* current target dialog */
kdumpLocation = _("On a mounted device");
} else if (target.type === "ftp") {
kdumpLocation = _("Remote over FTP");
} else if (target.type === "sftp") {
kdumpLocation = _("Remote over SFTP");
} else if (target.type === "cifs") {
kdumpLocation = _("Remote over CIFS/SMB");
} else {
kdumpLocation = _("No configuration found");
}
}
}
// this.storeLocation(this.props.kdumpStatus.config);
const settingsLink = targetCanChange && <Button variant="link" isInline id="kdump-change-target" onClick={this.handleSettingsClick}>{_("Edit")}</Button>;
let reservedMemory;
if (this.props.reservedMemory === undefined) {
// still waiting for result
reservedMemory = (
<div className="dialog-wait-ct">
<Spinner size="md" />
<span>{ _("Reading...") }</span>
</div>
);
} else if (this.props.reservedMemory === 0) {
// nothing reserved
reservedMemory = <span>{_("None")} </span>;
} else if (Number.isInteger(this.props.reservedMemory)) {
// TODO: hint at using debug_mem_level to identify actual memory required?
reservedMemory = <span>{cockpit.format_bytes(this.props.reservedMemory, { base2: true })}</span>;
} else {
// error while reading
reservedMemory = null;
}
const serviceRunning = this.props.kdumpStatus?.target &&
this.props.kdumpStatus?.installed &&
this.props.kdumpStatus?.state === "running";
let testButton;
if (serviceRunning) {
testButton = (
<PrivilegedButton variant="secondary" isDanger
excuse={ _("The user $0 is not permitted to test crash the kernel") }
onClick={this.handleTestSettingsClick}>
{ _("Test configuration") }
</PrivilegedButton>
);
} else {
const tooltip = _("Test is only available while the kdump service is running.");
testButton = (
<Tooltip id="tip-test" content={tooltip}>
<Button variant="secondary" isDanger isAriaDisabled>
{_("Test configuration")}
</Button>
</Tooltip>
);
}
let automationButton = null;
if (this.props.kdumpStatus && this.props.kdumpStatus.config !== null && this.state.os_release !== null && targetCanChange) {
automationButton = (
<FlexItem align={{ md: 'alignRight' }}>
<Button id="kdump-automation-script" variant="secondary" onClick={this.handleAutomationClick}>
{_("View automation script")}
</Button>
</FlexItem>
);
}
let kdumpSwitch;
let kdumpSwitchHelper;
if (!this.props.kdumpCmdlineEnabled) {
kdumpSwitchHelper = _("Currently not supported");
} else {
kdumpSwitch = (<Switch isChecked={!!serviceRunning}
onChange={this.props.onSetServiceState}
aria-label={_("kdump status")}
label={serviceRunning ? _("Enabled") : _("Disabled")}
isDisabled={this.props.stateChanging} />);
}
let alertMessage;
let alertDetail;
if (!this.props.stateChanging && this.props.kdumpStatus && this.props.kdumpStatus.installed !== undefined) {
if (this.props.kdumpStatus.installed) {
if (this.props.reservedMemory === 0) {
alertMessage = fmt_to_fragments(
_("Kernel did not boot with the $0 setting"),
<span className="pf-v6-u-font-family-monospace-vf">crashkernel</span>
);
alertDetail = fmt_to_fragments(
_("Reserve memory at boot time by setting a '$0' option on the kernel command line. For example, append '$1' to $2 in $3 or use your distribution's kernel argument editor."),
<span className="pf-v6-u-font-family-monospace-vf">crashkernel</span>,
<span className="pf-v6-u-font-family-monospace-vf">crashkernel=512M</span>,
<span className="pf-v6-u-font-family-monospace-vf">GRUB_CMDLINE_LINUX</span>,
<span className="pf-v6-u-font-family-monospace-vf">/etc/default/grub</span>
);
} else if (this.props.kdumpStatus.state === "failed") {
alertMessage = (
<>
{_("Service has an error")}
<Button variant="link" isInline className="pf-v6-u-ml-sm" onClick={this.handleServiceDetailsClick}>{_("more details")}</Button>
</>
);
}
} else {
alertMessage = _("Kdump service is not installed.");
alertDetail = fmt_to_fragments(
_("Install the $0 package."),
<span className="pf-v6-u-font-family-monospace-vf">kexec-tools</span>
);
}
}
return (
<Page className="pf-m-no-sidebar">
<PageSection hasBodyWrapper={false}>
<Flex spaceItems={{ default: 'spaceItemsMd' }} alignItems={{ default: 'alignItemsCenter' }}>
<Title headingLevel="h2" size="3xl">
{_("Kernel crash dump")}
</Title>
{kdumpSwitch}
{kdumpSwitchHelper &&
<HelperText className="subtle-helper-text">
<HelperTextItem>{kdumpSwitchHelper}</HelperTextItem>
</HelperText>}
{automationButton}
</Flex>
</PageSection>
<PageSection hasBodyWrapper={false}>
{alertMessage &&
<Alert variant='danger'
className="pf-v6-u-mb-md"
isLiveRegion={this.props.isLiveRegion}
isInline
title={alertMessage}>
{alertDetail}
</Alert>
}
<Card isPlain>
<CardTitle>
<Title headingLevel="h4" size="xl">
{_("Kdump settings")}
</Title>
</CardTitle>
<CardBody>
<DescriptionList className="pf-m-horizontal-on-sm">
<DescriptionListGroup>
<DescriptionListTerm>{_("Reserved memory")}</DescriptionListTerm>
<DescriptionListDescription>
{reservedMemory}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{_("Crash dump location")}</DescriptionListTerm>
<DescriptionListDescription>
<Flex spaceItems={{ default: 'spaceItemsSm' }}>
<span id="kdump-target-info">{ kdumpLocation }</span>
{settingsLink}
</Flex>
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm />
<DescriptionListDescription>
{testButton}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</CardBody>
</Card>
</PageSection>
</Page>
);
}
}