Line data Source code
1 : /*
2 : * Copyright (C) 2019 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 33 : import React, { useState } from "react";
7 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
8 : import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
9 : import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
10 : import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
11 : import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
12 : import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
13 : import { Tooltip, TooltipPosition } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
14 : import { Card, CardHeader, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js";
15 : import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List/index.js";
16 : import {
17 : Modal, ModalBody, ModalFooter, ModalHeader
18 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
19 : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
20 : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
21 : import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
22 : import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
23 : import {
24 : AsleepIcon,
25 : BanIcon, ErrorCircleOIcon, OnRunningIcon, OffIcon,
26 : ExclamationCircleIcon,
27 : OkIcon, UserIcon, ThumbtackIcon,
28 : } from "@patternfly/react-icons";
29 :
30 33 : import cockpit from "cockpit";
31 : import s_bus from "./busnames.js";
32 : import { systemd_client, MAX_UINT64 } from "./services.jsx";
33 : import * as timeformat from "timeformat";
34 : import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
35 : import { useDialogs, DialogsContext } from "dialogs.jsx";
36 : import { ModalError } from 'cockpit-components-inline-notification.jsx';
37 :
38 : import './service-details.scss';
39 : import { KebabDropdown } from "cockpit-components-dropdown";
40 :
41 : import { TimerDialog } from "./timer-dialog.jsx";
42 : import { from_boot_usec, from_on_calendar } from "./timer-dialog-helpers.js";
43 :
44 33 : const _ = cockpit.gettext;
45 33 : const METRICS_POLL_DELAY = 30000; // 30s
46 :
47 : // This marker comment is inserted at the beginning of unit files created by Cockpit
48 33 : export const CockpitManagedMarker = "# Managed by Cockpit.\n";
49 :
50 : /*
51 : * React template for showing basic dialog for confirming action
52 : * Required props:
53 : * - title
54 : * Title of the dialog
55 : * - message
56 : * Message in the dialog
57 : * - close
58 : * Action to be executed when Cancel button is selected.
59 : * Optional props:
60 : * - confirmText
61 : * Text of the button for confirming the action
62 : * - confirmAction
63 : * Action to be executed when the action is confirmed
64 : */
65 3 : const ServiceConfirmDialog = ({ id, title, message, confirmText, confirmAction }) => {
66 3 : const Dialogs = useDialogs();
67 3 : return (
68 3 : <Modal id={id} isOpen
69 3 : position="top" variant="medium"
70 3 : onClose={Dialogs.close}>
71 3 : <ModalHeader title={title} />
72 3 : <ModalBody>
73 3 : {message}
74 3 : </ModalBody>
75 3 : <ModalFooter>
76 3 : { confirmText && confirmAction &&
77 3 : <Button variant='danger' onClick={confirmAction}>
78 3 : {confirmText}
79 3 : </Button>
80 : }
81 3 : <Button variant='link' className='btn-cancel' onClick={Dialogs.close}>
82 3 : { _("Cancel") }
83 3 : </Button>
84 3 : </ModalFooter>
85 3 : </Modal>
86 : );
87 3 : };
88 :
89 : /*
90 : * React template for showing possible service action (in a kebab menu)
91 : * Required props:
92 : * - masked
93 : * Unit is masked
94 : * - active
95 : * Unit is active (running)
96 : * - failed
97 : * Unit has failed
98 : * - isPinned
99 : * Unit is pinned
100 : * - canReload
101 : * Unit can be reloaded
102 : * - actionCallback
103 : * Method for calling unit methods like `UnitStart`
104 : * - fileActionCallback
105 : * Method for calling unit file methods like `EnableUnitFiles`
106 : * - deleteActionCallback
107 : * Method for calling deleting the systemd unit
108 : * - pinUnitCallback
109 : * Method to pin unit
110 : * - disabled
111 : * Button is disabled
112 : */
113 23 : const ServiceActions = ({ masked, active, failed, canReload, actionCallback, editActionCallback, deleteActionCallback, fileActionCallback, disabled, isPinned, pinUnitCallback }) => {
114 23 : const Dialogs = useDialogs();
115 :
116 23 : const actions = [];
117 :
118 : // If masked, only show unmasking and nothing else
119 4 : if (masked) {
120 4 : actions.push(
121 2 : <DropdownItem key="unmask" onClick={() => fileActionCallback("UnmaskUnitFiles", undefined)}>{ _("Allow running (unmask)") }</DropdownItem>
122 4 : );
123 4 : } else { // All cases when not masked
124 19 : if (active) {
125 6 : if (canReload) {
126 6 : actions.push(
127 0 : <DropdownItem key="reload" onClick={() => actionCallback("ReloadUnit")}>{ _("Reload") }</DropdownItem>
128 6 : );
129 6 : }
130 19 : actions.push(
131 0 : <DropdownItem key="restart" onClick={() => actionCallback("RestartUnit")}>{ _("Restart") }</DropdownItem>
132 19 : );
133 19 : actions.push(
134 1 : <DropdownItem key="stop" onClick={() => actionCallback("StopUnit")}>{ _("Stop") }</DropdownItem>,
135 19 : );
136 13 : } else {
137 17 : actions.push(
138 3 : <DropdownItem key="start" onClick={() => actionCallback("StartUnit")}>{ _("Start") }</DropdownItem>
139 17 : );
140 17 : }
141 :
142 3 : if (editActionCallback || deleteActionCallback) {
143 3 : actions.push(<Divider key="modify-unit-divider" />);
144 3 : }
145 :
146 2 : if (editActionCallback) {
147 2 : actions.push(
148 1 : <DropdownItem key="edit" onClick={() => editActionCallback()}>{_("Edit")}</DropdownItem>
149 2 : );
150 2 : }
151 :
152 3 : if (deleteActionCallback) {
153 3 : actions.push(
154 3 : <DropdownItem key="delete" className="pf-m-danger"
155 1 : onClick={() => deleteActionCallback()}>{_("Delete")}</DropdownItem>
156 3 : );
157 3 : }
158 :
159 23 : if (actions.length > 0) {
160 23 : actions.push(
161 23 : <Divider key="divider" />
162 23 : );
163 23 : }
164 :
165 23 : if (failed)
166 5 : actions.push(
167 1 : <DropdownItem key="reset" onClick={() => actionCallback("ResetFailedUnit", []) }>{ _("Clear 'Failed to start'") }</DropdownItem>
168 5 : );
169 :
170 3 : const confirm = () => {
171 3 : Dialogs.show(<ServiceConfirmDialog id="mask-service"
172 3 : title={ _("Mask service") }
173 3 : message={ _("Masking service prevents all dependent units from running. This can have bigger impact than anticipated. Please confirm that you want to mask this unit.")}
174 3 : confirmText={ _("Mask service") }
175 3 : confirmAction={() => {
176 3 : fileActionCallback("MaskUnitFiles", false);
177 3 : if (failed)
178 1 : actionCallback("ResetFailedUnit", []);
179 3 : Dialogs.close();
180 3 : }} />);
181 3 : };
182 :
183 23 : actions.push(
184 23 : <DropdownItem key="mask" onClick={confirm}>{ _("Disallow running (mask)") }</DropdownItem>
185 23 : );
186 :
187 23 : actions.push(<Divider key="pin-divider" />);
188 23 : actions.push(
189 2 : <DropdownItem key="pin" onClick={() => pinUnitCallback() }>{isPinned ? _("Unpin unit") : _("Pin unit")}</DropdownItem>
190 23 : );
191 23 : }
192 :
193 23 : return (
194 23 : <KebabDropdown id="service-actions" dropdownItems={actions} isDisabled={disabled} title={ _("Additional actions") } />
195 : );
196 23 : };
197 :
198 : /*
199 : * React template for a service details
200 : * Shows current status and information about the service.
201 : * Enables user to control this unit like starting, enabling, etc. the service.
202 : * Required props:
203 : * - unit
204 : * as returned from systemd org.freedesktop.systemd1.{Unit,Socket}
205 : * D-Bus interface, but with unwrapped variants, and with additional "path"
206 : * property and addTimerProperties()
207 : * - permitted
208 : * True if user can control this unit
209 : * - systemdManager
210 : * Callback for displaying errors
211 : * - isValid
212 : * Method for finding if unit is valid
213 : */
214 33 : export class ServiceDetails extends React.Component {
215 33 : static contextType = DialogsContext;
216 :
217 24 : constructor(props) {
218 24 : super(props);
219 :
220 24 : this.state = {
221 24 : waitsAction: false,
222 24 : waitsFileAction: false,
223 24 : unit_properties: {},
224 24 : showDeleteDialog: false,
225 24 : unitPaths: [],
226 24 : cockpitManaged: false,
227 24 : isPinned: this.props.pinnedUnits.includes(this.props.unit.Id),
228 24 : };
229 :
230 24 : this.onOnOffSwitch = this.onOnOffSwitch.bind(this);
231 24 : this.unitAction = this.unitAction.bind(this);
232 24 : this.unitFileAction = this.unitFileAction.bind(this);
233 24 : this.editTimerAction = this.editTimerAction.bind(this);
234 24 : this.deleteAction = this.deleteAction.bind(this);
235 24 : this.deleteTimer = this.deleteTimer.bind(this);
236 24 : this.pinUnit = this.pinUnit.bind(this);
237 :
238 24 : this.unitType = props.unit.Id.split('.').slice(-1)[0];
239 24 : this.unitTypeCapitalized = this.unitType.charAt(0).toUpperCase() + this.unitType.slice(1);
240 24 : this.doMemoryCurrentPolling = this.doMemoryCurrentPolling.bind(this);
241 :
242 : // MemoryCurrent property does not emit a changed signal - do polling for this property
243 14 : if (props.unit.ActiveState == "active") {
244 14 : this.doMemoryCurrentPolling();
245 14 : this.interval = setInterval(this.doMemoryCurrentPolling, METRICS_POLL_DELAY);
246 14 : }
247 24 : }
248 :
249 24 : componentDidMount() {
250 16 : if (this.props.unit.FragmentPath?.startsWith("/etc/systemd")) {
251 16 : cockpit.file(this.props.unit.FragmentPath)
252 16 : .read()
253 15 : .then(content => {
254 1 : if (content?.startsWith(CockpitManagedMarker)) {
255 1 : this.setState({ cockpitManaged: true });
256 1 : }
257 15 : });
258 16 : }
259 24 : }
260 :
261 23 : componentDidUpdate(prevProps) {
262 : // If unit became active start property polling and if got inactive stop
263 8 : if (this.props.unit.ActiveState === 'active' && !this.interval) {
264 8 : this.doMemoryCurrentPolling();
265 8 : this.interval = setInterval(this.doMemoryCurrentPolling, METRICS_POLL_DELAY);
266 8 : }
267 11 : if (this.props.unit.ActiveState === 'inactive' && this.interval) {
268 11 : this.doMemoryCurrentPolling();
269 11 : clearInterval(this.interval);
270 11 : }
271 23 : }
272 :
273 10 : componentWillUnmount() {
274 10 : if (this.interval)
275 8 : clearInterval(this.interval);
276 10 : }
277 :
278 24 : static getDerivedStateFromProps(nextProps, prevState) {
279 24 : return {
280 24 : waitsAction: nextProps.loadingUnits,
281 24 : waitsFileAction: nextProps.loadingUnits,
282 24 : };
283 24 : }
284 :
285 0 : show_note(note) {
286 0 : const Dialogs = this.context;
287 0 : Dialogs.show(<ServiceConfirmDialog title={_("Note")} message={note} />);
288 0 : }
289 :
290 0 : show_error(error) {
291 0 : const Dialogs = this.context;
292 0 : Dialogs.show(<ServiceConfirmDialog title={_("Error")} message={error} />);
293 0 : }
294 :
295 20 : doMemoryCurrentPolling() {
296 20 : systemd_client[this.props.owner].call(this.props.unit.path,
297 20 : "org.freedesktop.DBus.Properties", "Get",
298 20 : ["org.freedesktop.systemd1." + this.unitTypeCapitalized, 'MemoryCurrent'])
299 17 : .then(result => {
300 17 : this.addUnitProperties(
301 17 : "MemoryCurrent",
302 9 : result[0] && result[0].v > 0 && result[0].v < MAX_UINT64 ? result[0].v : null,
303 17 : );
304 3 : }, ex => console.log(ex.message));
305 20 : }
306 :
307 17 : addUnitProperties(prop, value) {
308 17 : if (prop == "MemoryCurrent" && this.state.unit_properties.MemoryCurrent !== value)
309 17 : this.setState(prevState => ({ unit_properties: { ...prevState.unit_properties, [prop]: value } }));
310 17 : }
311 :
312 5 : onOnOffSwitch() {
313 3 : if (this.props.unit.UnitFileState === "enabled") {
314 3 : let promise;
315 1 : if (this.props.unit.ActiveState === "active" || this.props.unit.ActiveState === "activating")
316 0 : promise = this.unitAction("StopUnit");
317 1 : else if (this.props.unit.ActiveState === "failed")
318 0 : promise = this.unitAction("ResetFailedUnit", []);
319 : else
320 0 : promise = Promise.resolve();
321 :
322 3 : promise.then(() => this.unitFileAction("DisableUnitFiles", undefined));
323 3 : } else {
324 5 : this.unitFileAction("EnableUnitFiles", false)
325 5 : .then(() => {
326 5 : if (this.props.unit.ActiveState !== "active" && this.props.unit.ActiveState !== "activating")
327 5 : this.unitAction("StartUnit");
328 5 : });
329 5 : }
330 5 : }
331 :
332 9 : unitAction(method, extra_args, catchExc = true) {
333 9 : if (extra_args === undefined)
334 9 : extra_args = ["fail"];
335 9 : this.setState({ waitsAction: true });
336 9 : const promise = systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, method, [this.props.unit.Names[0]].concat(extra_args));
337 8 : if (catchExc) {
338 0 : return promise.catch(error => {
339 0 : this.show_error(error.toString());
340 0 : this.setState({ waitsAction: false });
341 0 : });
342 0 : } else {
343 1 : return promise;
344 1 : }
345 9 : }
346 :
347 2 : pinUnit() {
348 2 : const newPinned = this.state.isPinned
349 2 : ? this.props.pinnedUnits.filter(unitId => unitId != this.props.unit.Id)
350 2 : : [...this.props.pinnedUnits, this.props.unit.Id];
351 :
352 2 : localStorage.setItem('systemd:pinnedUnits', JSON.stringify(newPinned));
353 2 : this.setState(prevState => ({ isPinned: !prevState.isPinned }));
354 2 : dispatchEvent(new Event('storage'));
355 2 : }
356 :
357 5 : unitFileAction(method, force, catchExc = true) {
358 5 : this.setState({ waitsFileAction: true });
359 5 : const args = [[this.props.unit.Names[0]], false];
360 5 : if (force !== undefined)
361 5 : args.push(force == "true");
362 5 : const promise = systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, method, args)
363 5 : .then(([results]) => {
364 0 : if (results.length == 2 && !results[0])
365 0 : this.show_note(_("This unit is not designed to be enabled explicitly."));
366 : /* Executing daemon reload after file operations is necessary -
367 : * see https://github.com/systemd/systemd/blob/main/src/systemctl/systemctl.c [enable_unit function]
368 : */
369 5 : return systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, "Reload", null);
370 5 : });
371 5 : if (catchExc) {
372 0 : return promise.catch(error => {
373 0 : this.show_error(error.toString());
374 0 : this.setState({ waitsFileAction: false });
375 0 : });
376 0 : } else {
377 0 : return promise;
378 0 : }
379 5 : }
380 :
381 1 : editTimerAction() {
382 1 : async function getCommand(bus, serviceUnitName) {
383 1 : const serviceDbusPath = await bus.call(s_bus.O_MANAGER, s_bus.I_MANAGER, "LoadUnit", [serviceUnitName]);
384 1 : const serviceProperties = await bus.call(serviceDbusPath[0], s_bus.I_PROPS, "GetAll", [s_bus.I_SERVICE]);
385 1 : const exec = serviceProperties[0].ExecStart.v;
386 :
387 : // ensure there is exactly one ExecStart= in the unit file that matches "/bin/sh -c CMD"
388 :
389 0 : if (exec.length !== 1) {
390 0 : console.warn(`${exec.length} ExecStart= entries were found for ${serviceUnitName} instead of 1`);
391 0 : return null;
392 0 : }
393 :
394 1 : const [cmd, args] = exec[0];
395 0 : if (cmd !== "/bin/sh" || args.length !== 3 || args[1] != "-c") {
396 0 : console.warn(`ExecStart= entry is not of the form "/bin/sh -c CMD"`);
397 0 : return null;
398 0 : }
399 :
400 1 : return args[2];
401 1 : }
402 :
403 1 : const Dialogs = this.context;
404 :
405 1 : const timerName = this.props.unit.Id.slice(0, this.props.unit.Id.lastIndexOf("."));
406 1 : const serviceUnitName = timerName + ".service"; // timers created with cockpit only trigger one service
407 :
408 1 : const timerSettings = {
409 1 : name: timerName,
410 1 : description: this.props.unit.Description
411 1 : };
412 :
413 1 : let conditions = null;
414 1 : if (this.props.unit.TimersMonotonic.length > 0) {
415 1 : conditions = from_boot_usec(this.props.unit.TimersMonotonic[0][1]);
416 1 : } else if (this.props.unit.TimersCalendar.length > 0) {
417 1 : conditions = from_on_calendar(this.props.unit.TimersCalendar.map(item => item[1]));
418 1 : }
419 :
420 1 : getCommand(systemd_client[this.props.owner], serviceUnitName).then(command => {
421 1 : const timer = { ...timerSettings, ...conditions, command };
422 1 : Dialogs.show(<TimerDialog owner={this.props.owner} timer={timer} />);
423 1 : });
424 1 : }
425 :
426 1 : deleteAction() {
427 1 : this.getUnitPaths().then(unitPaths => {
428 1 : this.setState({ showDeleteDialog: true, unitPaths });
429 1 : });
430 1 : }
431 :
432 1 : async getUnitPaths() {
433 1 : const paths = [this.props.unit.FragmentPath];
434 :
435 1 : await Promise.all(this.props.unit.Triggers.map(async trigger => {
436 : // Getting dbus properties from a non-loaded unit is not possible so resort to systemctl show
437 1 : const unitPath = await cockpit.spawn(["systemctl", "show", "--value",
438 1 : "--property", "FragmentPath", trigger]);
439 1 : paths.push(unitPath.trim());
440 0 : })).catch(err => console.error("failed to look up unit details:", err.toString()));
441 :
442 1 : return paths;
443 1 : }
444 :
445 1 : deleteTimer() {
446 : // Stop timer so we don't get race conditions when the unit is gone.
447 1 : if (this.interval) {
448 1 : clearInterval(this.interval);
449 1 : this.interval = null;
450 1 : }
451 :
452 1 : const promises = [];
453 0 : if (this.props.unit.ActiveState === "active" || this.props.unit.ActiveState === "activating")
454 1 : promises.push(this.unitAction("StopUnit", undefined, false));
455 1 : if (this.props.unit.ActiveState === "failed")
456 0 : promises.push(this.unitAction("ResetFailedUnit", undefined, false));
457 1 : if (this.props.unit.UnitFileState === "enabled")
458 0 : promises.push(this.unitFileAction("DisableUnitFiles", undefined, false));
459 :
460 1 : return Promise.all(promises).then(() => {
461 1 : const deletions = this.state.unitPaths.filter(path => path.startsWith("/etc/systemd/system"))
462 1 : .map(path => cockpit.file(path, { superuser: "require" }).replace(null));
463 :
464 : // Reload after unit/timer removal
465 1 : return Promise.all(deletions).then(() =>
466 1 : systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, "Reload", null)
467 1 : .then(() => cockpit.jump("/system/services#/?type=timer"))
468 1 : );
469 1 : });
470 1 : }
471 :
472 24 : render() {
473 20 : const active = this.props.unit.ActiveState === "active" || this.props.unit.ActiveState === "activating";
474 24 : const enabled = this.props.unit.UnitFileState === "enabled";
475 22 : const isStatic = this.props.unit.UnitFileState !== "disabled" && !enabled;
476 24 : const failed = this.props.unit.ActiveState === "failed";
477 24 : const masked = this.props.unit.LoadState === "masked";
478 24 : const unit = this.state.unit_properties;
479 2 : const showAction = this.props.permitted || this.props.owner == "user";
480 12 : const isCustom = this.props.unit.FragmentPath.startsWith("/etc/systemd/system") && !masked;
481 24 : const isTimer = (this.unitType === "timer");
482 24 : const isQuadlet = this.props.unit.SourcePath?.includes("/containers/systemd/");
483 :
484 24 : let status = [];
485 :
486 4 : if (masked) {
487 4 : status.push(
488 4 : <div key="masked" className="status-masked">
489 4 : <Icon>
490 4 : <BanIcon className="status-icon" />
491 4 : </Icon>
492 4 : <span className="status">{ _("Masked") }</span>
493 4 : <span className="side-note font-xs">{ _("Forbidden from running") }</span>
494 4 : </div>
495 4 : );
496 4 : }
497 :
498 10 : if (!enabled && !active && !masked && !isStatic) {
499 10 : status.push(
500 10 : <div key="disabled" className="status-disabled">
501 10 : <Icon>
502 10 : <OffIcon className="status-icon" />
503 10 : </Icon>
504 10 : <span className="status">{ _("Disabled") }</span>
505 10 : </div>
506 10 : );
507 10 : }
508 :
509 5 : if (failed) {
510 5 : status.push(
511 5 : <div key="failed" className="status-failed">
512 5 : <Icon status="danger">
513 5 : <ErrorCircleOIcon className="status-icon" />
514 5 : </Icon>
515 5 : <span className="status">{ _("Failed to start") }</span>
516 5 : { showAction &&
517 2 : <Button variant="secondary" className="action-button" onClick={() => this.unitAction("StartUnit") }>{ _("Start service") }</Button>
518 : }
519 5 : </div>
520 5 : );
521 5 : }
522 :
523 23 : if (!status.length) {
524 20 : if (active) {
525 20 : status.push(
526 20 : <div key="running" className="status-running">
527 20 : <Icon status="success">
528 20 : <OnRunningIcon className="status-icon" />
529 20 : </Icon>
530 20 : <span className="status">{ _("Running") }</span>
531 20 : <span className="side-note font-xs">{ _("Active since ") + timeformat.dateTime(this.props.unit.ActiveEnterTimestamp / 1000) }</span>
532 20 : </div>
533 20 : );
534 15 : } else {
535 18 : status.push(
536 18 : <div key="stopped" className="status-stopped">
537 18 : <Icon>
538 18 : <OffIcon className="status-icon" />
539 18 : </Icon>
540 18 : <span className="status">{ _("Not running") }</span>
541 18 : </div>
542 18 : );
543 18 : }
544 23 : }
545 :
546 15 : if (isStatic && !masked) {
547 15 : status.unshift(
548 15 : <div key="static" className="status-static">
549 15 : <Icon>
550 15 : <AsleepIcon className="status-icon" />
551 15 : </Icon>
552 15 : <span className="status">{ _("Static") }</span>
553 15 : { this.props.unit.WantedBy && this.props.unit.WantedBy.length > 0 &&
554 2 : <>
555 2 : <span className="side-note font-xs">{ _("Required by ") }</span>
556 2 : <ul className="comma-list">
557 2 : {this.props.unit.WantedBy.map(unit => <li className="font-xs" key={unit}><a href={"#/" + unit}>{unit}</a></li>)}
558 2 : </ul>
559 2 : </>
560 : }
561 15 : </div>
562 15 : );
563 15 : }
564 :
565 2 : if (!showAction && this.props.owner !== 'user') {
566 2 : status.unshift(
567 2 : <div key="readonly" className="status-readonly">
568 2 : <Icon>
569 2 : <UserIcon className="status-icon" />
570 2 : </Icon>
571 2 : <span className="status">{ _("Read-only") }</span>
572 2 : <span className="side-note font-xs">{ _("Requires administration access to edit") }</span>
573 2 : </div>
574 2 : );
575 2 : }
576 :
577 12 : if (enabled) {
578 12 : status.push(
579 12 : <div key="enabled" className="status-enabled">
580 12 : <Icon status="success">
581 12 : <OkIcon className="status-icon" />
582 12 : </Icon>
583 12 : <span className="status">{ _("Automatically starts") }</span>
584 12 : </div>
585 12 : );
586 12 : }
587 :
588 1 : if (this.props.unit.NextRunTime || this.props.unit.LastTriggerTime) {
589 4 : status.push(
590 4 : <div className="service-unit-triggers" key="triggers">
591 4 : {this.props.unit.NextRunTime && <div className="service-unit-next-trigger">{cockpit.format("Next run: $0", this.props.unit.NextRunTime)}</div>}
592 4 : {this.props.unit.LastTriggerTime && <div className="service-unit-last-trigger">{cockpit.format("Last trigger: $0", this.props.unit.LastTriggerTime)}</div>}
593 4 : </div>
594 4 : );
595 4 : }
596 :
597 : /* If there is some ongoing action just show spinner */
598 9 : if (this.state.waitsAction || this.state.waitsFileAction) {
599 9 : status = [
600 9 : <div key="updating" className="status-updating">
601 9 : <Icon>
602 9 : <Spinner size="md" className="status-icon" />
603 9 : </Icon>
604 9 : <span className="status">{ _("Updating status...") }</span>
605 9 : </div>
606 9 : ];
607 9 : }
608 :
609 8 : const tooltipMessage = enabled ? _("Stop and disable") : _("Start and enable");
610 8 : const hasLoadError = this.props.unit.LoadState !== "loaded" && this.props.unit.LoadState !== "masked";
611 :
612 7 : if (hasLoadError) {
613 1 : const path = "/system/services" + (this.props.owner === "user" ? "#/?owner=user" : ""); // not-covered: OS error
614 1 : const loadError = this.props.unit.LoadError ? this.props.unit.LoadError[1] : null; // not-covered: OS error
615 1 : const title = loadError || _("Failed to load unit"); // not-covered: OS error
616 :
617 7 : return <EmptyStatePanel
618 7 : icon={ExclamationCircleIcon}
619 7 : title={title}
620 7 : paragraph={this.props.unitId}
621 7 : action={_("View all services")}
622 7 : actionVariant="link"
623 3 : onAction={() => cockpit.jump(path, cockpit.transport.host)}
624 7 : />;
625 7 : }
626 :
627 : // These are relevant for socket and timer activated services
628 24 : const triggerRelationships = [
629 24 : { Name: _("Triggers"), Units: this.props.unit.Triggers },
630 24 : { Name: _("Triggered by"), Units: this.props.unit.TriggeredBy },
631 24 : ];
632 :
633 24 : const relationships = [
634 24 : { Name: _("Requires"), Units: this.props.unit.Requires },
635 24 : { Name: _("Requisite"), Units: this.props.unit.Requisite },
636 24 : { Name: _("Wants"), Units: this.props.unit.Wants },
637 24 : { Name: _("Binds to"), Units: this.props.unit.BindsTo },
638 24 : { Name: _("Part of"), Units: this.props.unit.PartOf },
639 24 : { Name: _("Required by"), Units: this.props.unit.RequiredBy },
640 24 : { Name: _("Requisite of"), Units: this.props.unit.RequisiteOf },
641 24 : { Name: _("Wanted by"), Units: this.props.unit.WantedBy },
642 24 : { Name: _("Bound by"), Units: this.props.unit.BoundBy },
643 24 : { Name: _("Consists of"), Units: this.props.unit.ConsistsOf },
644 24 : { Name: _("Conflicts"), Units: this.props.unit.Conflicts },
645 24 : { Name: _("Conflicted by"), Units: this.props.unit.ConflictedBy },
646 24 : { Name: _("Before"), Units: this.props.unit.Before },
647 24 : { Name: _("After"), Units: this.props.unit.After },
648 24 : { Name: _("On failure"), Units: this.props.unit.OnFailure },
649 24 : { Name: _("Propagates reload to"), Units: this.props.unit.PropagatesReloadTo },
650 24 : { Name: _("Reload propagated from"), Units: this.props.unit.ReloadPropagatedFrom },
651 24 : { Name: _("Joins namespace of"), Units: this.props.unit.JoinsNamespaceOf }
652 24 : ];
653 :
654 24 : const relationshipsToList = rels => {
655 24 : return rels.filter(rel => rel.Units && rel.Units.length > 0)
656 24 : .map(rel =>
657 24 : <DescriptionListGroup key={rel.Name}>
658 24 : <DescriptionListTerm>{rel.Name}</DescriptionListTerm>
659 24 : <DescriptionListDescription id={rel.Name.split(" ").join("")}>
660 24 : <ul className="comma-list">
661 2 : {rel.Units.map(unit => <li key={unit}><Button isInline variant="link" component="a" href={"#/" + unit + (this.props.owner === "user" ? "?owner=user" : "")} isDisabled={!this.props.isValid(unit)}>{unit}</Button></li>)}
662 24 : </ul>
663 24 : </DescriptionListDescription>
664 24 : </DescriptionListGroup>
665 24 : );
666 24 : };
667 :
668 24 : const triggerRelationshipsList = relationshipsToList(triggerRelationships);
669 :
670 24 : const extraRelationshipsList = relationshipsToList(relationships);
671 :
672 24 : const conditions = this.props.unit.Conditions;
673 24 : const notMetConditions = [];
674 24 : if (conditions)
675 1 : conditions.forEach(condition => {
676 1 : if (condition[4] < 0)
677 1 : notMetConditions.push(cockpit.format(_("Condition $0=$1 was not met"), condition[0], condition[3]));
678 1 : });
679 :
680 24 : return (
681 24 : <Card isPlain id="service-details-unit" className="ct-card">
682 24 : { this.state.showDeleteDialog &&
683 2 : <DeleteModal
684 2 : name={this.props.unit.Description}
685 0 : handleCancel={() => this.setState({ showDeleteDialog: false })}
686 2 : handleDelete={this.deleteTimer}
687 2 : reason={<Flex>
688 2 : <p>{_("Deletion will remove the following files:")}</p>
689 2 : <List>
690 1 : {this.state.unitPaths.map(item => <ListItem key={item}>{item}</ListItem>)}
691 2 : </List>
692 2 : </Flex>
693 : }
694 2 : />
695 : }
696 24 : <CardHeader>
697 24 : <Flex className="service-top-panel" spaceItems={{ default: 'spaceItemsMd' }} alignItems={{ default: 'alignItemsCenter' }}>
698 24 : <CardTitle component="h2" className="service-name">{this.props.unit.Description}</CardTitle>
699 24 : {this.state.isPinned &&
700 3 : <Tooltip content={_("Pinned unit")}>
701 3 : <ThumbtackIcon className='service-thumbtack-icon' />
702 3 : </Tooltip>}
703 24 : { showAction &&
704 23 : <>
705 23 : { !masked && !isStatic &&
706 14 : <Tooltip id="switch-unit-state" content={tooltipMessage} position={TooltipPosition.right}>
707 14 : <Switch isChecked={enabled}
708 14 : aria-label={tooltipMessage}
709 14 : isDisabled={this.state.waitsAction || this.state.waitsFileAction}
710 14 : onChange={this.onOnOffSwitch} />
711 14 : </Tooltip>
712 : }
713 23 : <ServiceActions { ...{ active, failed, enabled, masked } } canReload={this.props.unit.CanReload}
714 23 : actionCallback={this.unitAction} fileActionCallback={this.unitFileAction}
715 2 : editActionCallback={isCustom && isTimer && this.state.cockpitManaged ? this.editTimerAction : null}
716 2 : deleteActionCallback={isCustom && isTimer ? this.deleteAction : null}
717 23 : disabled={this.state.waitsAction || this.state.waitsFileAction}
718 23 : isPinned={this.state.isPinned} pinUnitCallback={this.pinUnit} />
719 23 : </>
720 : }
721 24 : </Flex>
722 24 : </CardHeader>
723 24 : <CardBody>
724 24 : <Stack hasGutter>
725 24 : <DescriptionList isHorizontal>
726 24 : <DescriptionListGroup>
727 24 : <DescriptionListTerm>{ _("Status") }</DescriptionListTerm>
728 24 : <DescriptionListDescription id="statuses">
729 24 : { status }
730 24 : </DescriptionListDescription>
731 24 : </DescriptionListGroup>
732 24 : <DescriptionListGroup>
733 24 : <DescriptionListTerm>{ _("Path") }</DescriptionListTerm>
734 24 : <DescriptionListDescription id="path">{this.props.unit.FragmentPath}</DescriptionListDescription>
735 24 : </DescriptionListGroup>
736 24 : {unit.MemoryCurrent
737 15 : ? <DescriptionListGroup>
738 15 : <DescriptionListTerm>{ _("Memory") }</DescriptionListTerm>
739 15 : <DescriptionListDescription id="memory">{cockpit.format_bytes(unit.MemoryCurrent)}</DescriptionListDescription>
740 15 : </DescriptionListGroup>
741 24 : : null}
742 3 : {this.props.unit.Listen && this.props.unit.Listen.length && <DescriptionListGroup>
743 3 : <DescriptionListTerm>{ _("Listen") }</DescriptionListTerm>
744 3 : <DescriptionListDescription id="listen">
745 3 : {cockpit.format("$0 ($1)", this.props.unit.Listen[0][1], this.props.unit.Listen[0][0])}
746 3 : </DescriptionListDescription>
747 3 : </DescriptionListGroup>}
748 1 : {isQuadlet && cockpit.manifests?.podman?.capabilities?.includes("service-filtering") && <DescriptionListGroup>
749 1 : <DescriptionListTerm>{ _("Container") }</DescriptionListTerm>
750 1 : <DescriptionListDescription id="container">
751 1 : <Button variant="link" isInline onClick={
752 0 : () => cockpit.jump(`/podman#/?service=${this.props.unit.Id}`)}>
753 1 : {_("View Podman container")}
754 1 : </Button>
755 1 : </DescriptionListDescription>
756 1 : </DescriptionListGroup>}
757 24 : { notMetConditions.length > 0 &&
758 2 : <DescriptionListGroup>
759 2 : <DescriptionListTerm className="failed">{ _("Condition failed") }</DescriptionListTerm>
760 2 : <DescriptionListDescription id="condition">
761 1 : {notMetConditions.map(cond => <div key={cond}>{cond}</div>)}
762 2 : </DescriptionListDescription>
763 2 : </DescriptionListGroup>
764 : }
765 24 : {triggerRelationshipsList}
766 24 : </DescriptionList>
767 24 : {extraRelationshipsList.length
768 2 : ? <ExpandableSection id="service-details-show-relationships" toggleText={triggerRelationshipsList.length ? _("Show more relationships") : _("Show relationships")}>
769 24 : <DescriptionList isHorizontal>
770 24 : {extraRelationshipsList}
771 24 : </DescriptionList>
772 24 : </ExpandableSection>
773 3 : : null}
774 24 : </Stack>
775 24 : </CardBody>
776 24 : </Card>
777 : );
778 24 : }
779 33 : }
780 :
781 1 : const DeleteModal = ({ reason, name, handleCancel, handleDelete }) => {
782 1 : const [inProgress, setInProgress] = useState(false);
783 1 : const [dialogError, setDialogError] = useState(undefined);
784 1 : return (
785 1 : <Modal isOpen
786 1 : position="top" variant="medium"
787 1 : onClose={handleCancel}
788 : >
789 1 : <ModalHeader title={cockpit.format(_("Confirm deletion of $0"), name)}
790 1 : titleIconVariant="warning"
791 1 : />
792 1 : <ModalBody>
793 1 : <Stack hasGutter>
794 0 : {dialogError && <ModalError dialogError={_("Timer deletion failed")} dialogErrorDetail={dialogError} />}
795 1 : {reason}
796 1 : </Stack>
797 1 : </ModalBody>
798 1 : <ModalFooter>
799 1 : <Button id="delete-timer-modal-btn" variant="danger" isDisabled={inProgress} isLoading={inProgress}
800 0 : onClick={() => { setInProgress(true); handleDelete().catch(exc => { setDialogError(exc.message); setInProgress(false) }) }}
801 : >
802 1 : {_("Delete")}
803 1 : </Button>
804 1 : <Button variant="link" isDisabled={inProgress} onClick={handleCancel}>{_("Cancel")}</Button>
805 1 : </ModalFooter>
806 1 : </Modal>
807 : );
808 1 : };
|