cockpit/pkg/systemd/service-details.jsx

524 lines
22 KiB
JavaScript

/*
* This file is part of Cockpit.
*
* Copyright (C) 2019 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 React from "react";
import moment from "moment";
import PropTypes from "prop-types";
import { Button, Modal, OverlayTrigger, Tooltip, DropdownKebab, MenuItem } from 'patternfly-react';
import cockpit from "cockpit";
import { OnOffSwitch } from "cockpit-components-onoff.jsx";
import './service-details.css';
const _ = cockpit.gettext;
/*
* React template for instantiating service templates
* Required props:
* - template:
* Name of the template
* - instantiateCallback
* Method for calling unit file methods like `EnableUnitFiles`
*/
export class ServiceTemplate extends React.Component {
constructor(props) {
super(props);
this.state = {
inputText: "",
};
this.handleChange = this.handleChange.bind(this);
}
handleChange(e) {
this.setState({ inputText: e.target.value });
}
render() {
return (
<div className="panel panel-default">
<div className="list-group">
<div className="list-group-item">
{ cockpit.format(_("$0 Template"), this.props.template) }
</div>
<div className="list-group-item">
<input type="text" onChange={ this.handleChange } />
<button onClick={() => this.props.instantiateCallback(this.state.inputText)}>{ _("Instantiate") }</button>
</div>
</div>
</div>
);
}
}
ServiceTemplate.propTypes = {
template: PropTypes.string.isRequired,
instantiateCallback: PropTypes.func.isRequired,
};
/*
* Note:
<p translatable="yes">This unit is not designed to be enabled explicitly.</p>
Error:
error.toString()
*/
/*
* React template for showing basic dialog for confirming action
* Required props:
* - title
* Title of the dialog
* - message
* Message in the dialog
* - close
* Action to be executed when Cancel button is selected.
* Optional props:
* - confirmText
* Text of the button for confirming the action
* - confirmAction
* Action to be executed when the action is confirmed
*/
class ServiceConfirmDialog extends React.Component {
render() {
return (
<Modal show>
<Modal.Header>
<Modal.Title>{this.props.title}</Modal.Title>
</Modal.Header>
<Modal.Body>
{this.props.message}
</Modal.Body>
<Modal.Footer>
<Button bsStyle='default' className='btn-cancel' onClick={this.props.close}>
{ _("Cancel") }
</Button>
{ this.props.confirmText && this.props.confirmAction &&
<Button bsStyle='danger' onClick={this.props.confirmAction}>
{this.props.confirmText}
</Button>
}
</Modal.Footer>
</Modal>
);
}
}
ServiceConfirmDialog.propTypes = {
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
close: PropTypes.func.isRequired,
confirmText: PropTypes.string,
confirmAction: PropTypes.func,
};
/*
* React template for showing possible service action (in a kebab menu)
* Required props:
* - masked
* Unit is masked
* - active
* Unit is active (running)
* - failed
* Unit has failed
* - canReload
* Unit can be reloaded
* - actionCallback
* Method for calling unit methods like `UnitStart`
* - fileActionCallback
* Method for calling unit file methods like `EnableUnitFiles`
* - disabled
* Button is disabled
*/
class ServiceActions extends React.Component {
constructor(props) {
super(props);
this.state = {
dialogMaskedOpened: false,
};
}
render() {
const actions = [];
// If masked, only show unmasking and nothing else
if (this.props.masked) {
actions.push(
<MenuItem key="unmask" onClick={() => this.props.fileActionCallback("UnmaskUnitFiles", undefined)}>{ _("Allow running (unmask)") }</MenuItem>
);
} else { // All cases when not masked
if (this.props.active) {
if (this.props.canReload) {
actions.push(
<MenuItem key="reload" onClick={() => this.props.actionCallback("ReloadUnit")}>{ _("Reload") }</MenuItem>
);
}
actions.push(
<MenuItem key="restart" onClick={() => this.props.actionCallback("RestartUnit")}>{ _("Restart") }</MenuItem>
);
actions.push(
<MenuItem key="stop" onClick={() => this.props.actionCallback("StopUnit")}>{ _("Stop") }</MenuItem>,
);
} else {
actions.push(
<MenuItem key="start" onClick={() => this.props.actionCallback("StartUnit")}>{ _("Start") }</MenuItem>
);
}
if (actions.length > 0) {
actions.push(
<MenuItem key="divider2" divider />
);
}
if (this.props.failed)
actions.push(
<MenuItem key="reset" onClick={() => this.props.actionCallback("ResetFailedUnit", []) }>{ _("Clear 'Failed to start'") }</MenuItem>
);
actions.push(
<MenuItem key="mask" onClick={() => this.setState({ dialogMaskedOpened: true }) }>{ _("Disallow running (mask)") }</MenuItem>
);
}
return (
<>
{ this.state.dialogMaskedOpened &&
<ServiceConfirmDialog title={ _("Mask Service") }
message={ _("Masking service prevents all dependant units from running. This can have bigger impact than anticipated. Please confirm that you want to mask this unit.")}
close={() => this.setState({ dialogMaskedOpened: false }) }
confirmText={ _("Mask Service") }
confirmAction={() => {
this.props.fileActionCallback("MaskUnitFiles", false);
this.props.actionCallback("ResetFailedUnit", []);
this.setState({ dialogMaskedOpened: false });
}} />
}
<DropdownKebab id="service-actions" title={ _("Additional actions") } className={this.props.disabled ? "disabled" : "" }>
{actions}
</DropdownKebab>
</>
);
}
}
ServiceActions.propTypes = {
masked: PropTypes.bool.isRequired,
active: PropTypes.bool.isRequired,
failed: PropTypes.bool.isRequired,
canReload: PropTypes.bool,
actionCallback: PropTypes.func.isRequired,
fileActionCallback: PropTypes.func.isRequired,
disabled: PropTypes.bool,
};
/*
* React template for a service details
* Shows current status and informations about the service.
* Enables user to control this unit like starting, enabling, etc. the service.
* Required props:
* - unit
* Unit as returned from systemd dbus API
* - permitted
* True if user can control this unit
* - systemdManager
* Callback for displaying errors
* - isValid
* Method for finding if unit is valid
* Optional props:
* - originTemplate
* Template name, from which this unit has been initialized
*/
export class ServiceDetails extends React.Component {
constructor(props) {
super(props);
this.state = {
waitsAction: false,
waitsFileAction: false,
note: "",
error: "",
};
this.onOnOffSwitch = this.onOnOffSwitch.bind(this);
this.unitAction = this.unitAction.bind(this);
this.unitFileAction = this.unitFileAction.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
// Don't update when only actions resolved, wait until API triggers some redrawing
if ((nextState.waitsAction === false && this.state.waitsAction === true) ||
(nextState.waitsFileAction === false && this.state.waitsFileAction === true))
return false;
return true;
}
onOnOffSwitch() {
if (this.props.unit.UnitFileState === "enabled") {
this.unitFileAction("DisableUnitFiles", undefined);
if (this.props.unit.ActiveState === "active" || this.props.unit.ActiveState === "activating")
this.unitAction("StopUnit");
if (this.props.unit.ActiveState === "failed")
this.unitAction("ResetFailedUnit", []);
} else {
this.unitFileAction("EnableUnitFiles", false);
if (this.props.unit.ActiveState !== "active" && this.props.unit.ActiveState !== "activating")
this.unitAction("StartUnit");
}
}
unitAction(method, extra_args) {
if (extra_args === undefined)
extra_args = ["fail"];
this.setState({ waitsAction: true });
this.props.systemdManager.call(method, [this.props.unit.Names[0]].concat(extra_args))
.fail(error => this.setState({ error: error.toString() }))
.finally(() => this.setState({ waitsAction: false }));
}
unitFileAction(method, force) {
this.setState({ waitsFileAction: true });
const args = [[this.props.unit.Names[0]], false];
if (force !== undefined)
args.push(force == "true");
this.props.systemdManager.call(method, args)
.then(results => {
if (results.length == 2 && !results[0])
this.setState({ note:_("This unit is not designed to be enabled explicitly.") });
this.props.systemdManager.Reload()
.then(() => this.setState({ waitsFileAction: false }));
})
.fail(error => {
this.setState({
error: error.toString(),
waitsFileAction: false
});
});
}
render() {
const active = this.props.unit.ActiveState === "active" || this.props.unit.ActiveState === "activating";
const enabled = this.props.unit.UnitFileState === "enabled";
const isStatic = this.props.unit.UnitFileState !== "disabled" && !enabled;
const failed = this.props.unit.ActiveState === "failed";
const masked = this.props.unit.LoadState === "masked";
let status = [];
if (masked) {
status.push(
<div key="masked" className="status-masked">
<span className="fa fa-ban status-icon" />
<span className="status">{ _("Masked") }</span>
<span className="side-note font-xs">{ _("Forbidden from running") }</span>
</div>
);
}
if (!enabled && !active && !masked && !isStatic) {
status.push(
<div key="disabled" className="status-disabled">
<span className="pficon pficon-off status-icon" />
<span className="status">{ _("Disabled") }</span>
</div>
);
}
if (failed) {
status.push(
<div key="failed" className="status-failed">
<span className="pficon pficon-error-circle-o status-icon" />
<span className="status">{ _("Failed to start") }</span>
<button className="btn btn-default action-button" onClick={() => this.unitAction("StartUnit") }>{ _("Start Service") }</button>
</div>
);
}
if (!status.length) {
if (active) {
status.push(
<div key="running" className="status-running">
<span className="pficon pficon-on-running status-icon" />
<span className="status">{ _("Running") }</span>
<span className="side-note font-xs">{ _("Active since ") + moment(this.props.unit.ActiveEnterTimestamp / 1000).format('LLL') }</span>
</div>
);
} else {
status.push(
<div key="stopped" className="status-stopped">
<span className="pficon pficon-off status-icon" />
<span className="status">{ _("Not running") }</span>
</div>
);
}
}
if (isStatic && !masked) {
status.unshift(
<div key="static" className="status-static">
<span className="pficon pficon-asleep status-icon" />
<span className="status">{ _("Static") }</span>
{ this.props.unit.WantedBy && this.props.unit.WantedBy.length > 0 &&
<>
<span className="side-note font-xs">{ _("Required by ") }</span>
<ul className="comma-list">
{this.props.unit.WantedBy.map(unit => <li className="font-xs" key={unit}><a href={"#/" + unit}>{unit}</a></li>)}
</ul>
</>
}
</div>
);
}
if (!this.props.permitted) {
status.unshift(
<div key="readonly" className="status-readonly">
<span className="fa fa-user status-icon" />
<span className="status">{ _("Read-only") }</span>
<span className="side-note font-xs">{ _("Requires administration access to edit") }</span>
</div>
);
}
if (enabled) {
status.push(
<div key="enabled" className="status-enabled">
<span className="pficon pficon-ok status-icon" />
<span className="status">{ _("Automatically starts") }</span>
</div>
);
}
/* If there is some ongoing action just show spinner */
if (this.state.waitsAction || this.state.waitsFileAction) {
status = [
<div key="updating" className="status-updating">
<span className="spinner spinner-inline spinner-xs status-icon" />
<span className="status">{ _("Updating status...") }</span>
</div>
];
}
const tooltipMessage = enabled ? _("Stop and Disable") : _("Start and Enable");
const hasLoadError = this.props.unit.LoadState !== "loaded" && this.props.unit.LoadState !== "masked";
const loadError = this.props.unit.LoadError ? this.props.unit.LoadError[1] : null;
const relationships = [
{ Name: _("Requires"), Units: this.props.unit.Requires },
{ Name: _("Requisite"), Units: this.props.unit.Requisite },
{ Name: _("Wants"), Units: this.props.unit.Wants },
{ Name: _("Binds To"), Units: this.props.unit.BindsTo },
{ Name: _("Part Of"), Units: this.props.unit.PartOf },
{ Name: _("Required By"), Units: this.props.unit.RequiredBy },
{ Name: _("Requisite Of"), Units: this.props.unit.RequisiteOf },
{ Name: _("Wanted By"), Units: this.props.unit.WantedBy },
{ Name: _("Bound By"), Units: this.props.unit.BoundBy },
{ Name: _("Consists Of"), Units: this.props.unit.ConsistsOf },
{ Name: _("Conflicts"), Units: this.props.unit.Conflicts },
{ Name: _("Conflicted By"), Units: this.props.unit.ConflictedBy },
{ Name: _("Before"), Units: this.props.unit.Before },
{ Name: _("After"), Units: this.props.unit.After },
{ Name: _("On Failure"), Units: this.props.unit.OnFailure },
{ Name: _("Triggers"), Units: this.props.unit.Triggers },
{ Name: _("Triggered By"), Units: this.props.unit.TriggeredBy },
{ Name: _("Propagates Reload To"), Units: this.props.unit.PropagatesReloadTo },
{ Name: _("Reload Propagated From"), Units: this.props.unit.ReloadPropagatedFrom },
{ Name: _("Joins Namespace Of"), Units: this.props.unit.JoinsNamespaceOf }
];
const conditions = this.props.unit.Conditions;
const notMetConditions = [];
if (conditions)
conditions.map(condition => {
if (condition[4] < 0)
notMetConditions.push(cockpit.format(_("Condition $0=$1 was not met"), condition[0], condition[3]));
});
return (
<>
{ (this.state.note || this.state.error) &&
<ServiceConfirmDialog title={ this.state.error ? _("Error") : _("Note") }
message={ this.state.error || this.state.note }
close={ () => this.setState(this.state.error ? { error:"" } : { note:"" }) }
/>
}
{ hasLoadError
? <div className="alert alert-danger">
<span className="pficon pficon-error-circle-o" />
<strong>{this.props.unit.LoadState}</strong>
{loadError}
</div>
: <>
<div className="service-top-panel">
<h2 className="service-name">{this.props.unit.Description}</h2>
{ this.props.permitted &&
<>
{ !masked && !isStatic &&
<OverlayTrigger overlay={ <Tooltip id="switch-unit-state">{ tooltipMessage }</Tooltip> } placement='right'>
<span>
<OnOffSwitch state={enabled} disabled={this.state.waitsAction || this.state.waitsFileAction} onChange={this.onOnOffSwitch} />
</span>
</OverlayTrigger>
}
<ServiceActions { ...{ active, failed, enabled, masked } } canReload={this.props.unit.CanReload} actionCallback={this.unitAction} fileActionCallback={this.unitFileAction} disabled={this.state.waitsAction || this.state.waitsFileAction} />
</>
}
</div>
<form className="ct-form">
<label className="control-label" htmlFor="statuses">{ _("Status") }</label>
<div id="statuses" className="ct-validation-wrapper">
{ status }
</div>
<hr />
<label className="control-label" htmlFor="path">{ _("Path") }</label>
<span id="path">{this.props.unit.FragmentPath}</span>
<hr />
{ this.props.originTemplate &&
<>
<div />
<span>{_("Instance of template: ")}<a href={"#/" + this.props.originTemplate}>{this.props.originTemplate}</a></span>
</>
}
{ notMetConditions.length > 0 &&
<>
<label className="control-label failed" htmlFor="condition">{ _("Condition failed") }</label>
<div id="condition" className="ct-validation-wrapper">
{notMetConditions.map(cond => <div key={cond.split(' ').join('')}>{cond}</div>)}
</div>
</>
}
<hr />
{relationships.map(rel =>
rel.Units && rel.Units.length > 0 &&
<React.Fragment key={rel.Name.split().join("")}>
<label className="control-label closer-lines" htmlFor={rel.Name}>{rel.Name}</label>
<ul id={rel.Name.split(" ").join("")} className="comma-list closer-lines">
{rel.Units.map(unit => <li key={unit}><a href={"#/" + unit} className={this.props.isValid(unit) ? "" : "disabled"}>{unit}</a></li>)}
</ul>
</React.Fragment>
)}
</form>
</>
}
</>
);
}
}
ServiceDetails.propTypes = {
unit: PropTypes.object.isRequired,
originTemplate: PropTypes.string,
permitted: PropTypes.bool.isRequired,
systemdManager: PropTypes.object.isRequired,
isValid: PropTypes.func.isRequired,
};