LCOV - code coverage report
Current view: top level - pkg/systemd - service-details.jsx Coverage Total Hit
Test: cockpit Lines: 93.0 % 610 567
Test Date: 2026-06-25 11:17:56

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2019 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           34 : 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           34 : 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           34 : const _ = cockpit.gettext;
      45           34 : const METRICS_POLL_DELAY = 30000; // 30s
      46              : 
      47              : // This marker comment is inserted at the beginning of unit files created by Cockpit
      48           34 : 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           14 :         } else {
     137           18 :             actions.push(
     138            3 :                 <DropdownItem key="start" onClick={() => actionCallback("StartUnit")}>{ _("Start") }</DropdownItem>
     139           18 :             );
     140           18 :         }
     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           34 : export class ServiceDetails extends React.Component {
     215           34 :     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           13 :         if (props.unit.ActiveState == "active") {
     244           13 :             this.doMemoryCurrentPolling();
     245           13 :             this.interval = setInterval(this.doMemoryCurrentPolling, METRICS_POLL_DELAY);
     246           13 :         }
     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           22 :     componentDidUpdate(prevProps) {
     262              :         // If unit became active start property polling and if got inactive stop
     263            9 :         if (this.props.unit.ActiveState === 'active' && !this.interval) {
     264            9 :             this.doMemoryCurrentPolling();
     265            9 :             this.interval = setInterval(this.doMemoryCurrentPolling, METRICS_POLL_DELAY);
     266            9 :         }
     267           11 :         if (this.props.unit.ActiveState === 'inactive' && this.interval) {
     268           11 :             this.doMemoryCurrentPolling();
     269           11 :             clearInterval(this.interval);
     270           11 :         }
     271           22 :     }
     272              : 
     273           12 :     componentWillUnmount() {
     274           12 :         if (this.interval)
     275           10 :             clearInterval(this.interval);
     276           12 :     }
     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           16 :                 .then(result => {
     300           16 :                     this.addUnitProperties(
     301           16 :                         "MemoryCurrent",
     302            8 :                         result[0] && result[0].v > 0 && result[0].v < MAX_UINT64 ? result[0].v : null,
     303           16 :                     );
     304            3 :                 }, ex => console.log(ex.message));
     305           20 :     }
     306              : 
     307           16 :     addUnitProperties(prop, value) {
     308           16 :         if (prop == "MemoryCurrent" && this.state.unit_properties.MemoryCurrent !== value)
     309           16 :             this.setState({ unit_properties: Object.assign(this.state.unit_properties, { [prop]: value }) });
     310           16 :     }
     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            9 :         if (!enabled && !active && !masked && !isStatic) {
     499            9 :             status.push(
     500            9 :                 <div key="disabled" className="status-disabled">
     501            9 :                     <Icon>
     502            9 :                         <OffIcon className="status-icon" />
     503            9 :                     </Icon>
     504            9 :                     <span className="status">{ _("Disabled") }</span>
     505            9 :                 </div>
     506            9 :             );
     507            9 :         }
     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           13 :                                 ? <DescriptionListGroup>
     738           13 :                                     <DescriptionListTerm>{ _("Memory") }</DescriptionListTerm>
     739           13 :                                     <DescriptionListDescription id="memory">{cockpit.format_bytes(unit.MemoryCurrent)}</DescriptionListDescription>
     740           13 :                                 </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           34 : }
     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 : };
        

Generated by: LCOV version 2.0-1