🏡 index : quine-global-labs/mirror-cockpit-project-cockpit.git

/*
 * Copyright (C) 2019 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

import React, { useState } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
import { Tooltip, TooltipPosition } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
import { Card, CardHeader, CardBody, CardTitle } from "@patternfly/react-core/dist/esm/components/Card/index.js";
import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List/index.js";
import {
    Modal, ModalBody, ModalFooter, ModalHeader
} from '@patternfly/react-core/dist/esm/components/Modal/index.js';
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
import {
    AsleepIcon,
    BanIcon, ErrorCircleOIcon, OnRunningIcon, OffIcon,
    ExclamationCircleIcon,
    OkIcon, UserIcon, ThumbtackIcon,
} from "@patternfly/react-icons";

import cockpit from "cockpit";
import s_bus from "./busnames.js";
import { systemd_client, MAX_UINT64 } from "./services.jsx";
import * as timeformat from "timeformat";
import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
import { useDialogs, DialogsContext } from "dialogs.jsx";
import { ModalError } from 'cockpit-components-inline-notification.jsx';

import './service-details.scss';
import { KebabDropdown } from "cockpit-components-dropdown";

import { TimerDialog } from "./timer-dialog.jsx";
import { from_boot_usec, from_on_calendar } from "./timer-dialog-helpers.js";

const _ = cockpit.gettext;
const METRICS_POLL_DELAY = 30000; // 30s

// This marker comment is inserted at the beginning of unit files created by Cockpit
export const CockpitManagedMarker = "# Managed by Cockpit.\n";

/*
 * 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
 */
const ServiceConfirmDialog = ({ id, title, message, confirmText, confirmAction }) => {
    const Dialogs = useDialogs();
    return (
        <Modal id={id} isOpen
               position="top" variant="medium"
               onClose={Dialogs.close}>
            <ModalHeader title={title} />
            <ModalBody>
                {message}
            </ModalBody>
            <ModalFooter>
                { confirmText && confirmAction &&
                <Button variant='danger' onClick={confirmAction}>
                    {confirmText}
                </Button>
                }
                <Button variant='link' className='btn-cancel' onClick={Dialogs.close}>
                    { _("Cancel") }
                </Button>
            </ModalFooter>
        </Modal>
    );
};

/*
 * 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
 *  - isPinned
 *      Unit is pinned
 *  - canReload
 *      Unit can be reloaded
 *  - actionCallback
 *      Method for calling unit methods like `UnitStart`
 *  - fileActionCallback
 *      Method for calling unit file methods like `EnableUnitFiles`
 *  - deleteActionCallback
 *      Method for calling deleting the systemd unit
 *  - pinUnitCallback
 *      Method to pin unit
 *  - disabled
 *      Button is disabled
 */
const ServiceActions = ({ masked, active, failed, canReload, actionCallback, editActionCallback, deleteActionCallback, fileActionCallback, disabled, isPinned, pinUnitCallback }) => {
    const Dialogs = useDialogs();

    const actions = [];

    // If masked, only show unmasking and nothing else
    if (masked) {
        actions.push(
            <DropdownItem key="unmask" onClick={() => fileActionCallback("UnmaskUnitFiles", undefined)}>{ _("Allow running (unmask)") }</DropdownItem>
        );
    } else { // All cases when not masked
        if (active) {
            if (canReload) {
                actions.push(
                    <DropdownItem key="reload" onClick={() => actionCallback("ReloadUnit")}>{ _("Reload") }</DropdownItem>
                );
            }
            actions.push(
                <DropdownItem key="restart" onClick={() => actionCallback("RestartUnit")}>{ _("Restart") }</DropdownItem>
            );
            actions.push(
                <DropdownItem key="stop" onClick={() => actionCallback("StopUnit")}>{ _("Stop") }</DropdownItem>,
            );
        } else {
            actions.push(
                <DropdownItem key="start" onClick={() => actionCallback("StartUnit")}>{ _("Start") }</DropdownItem>
            );
        }

        if (editActionCallback || deleteActionCallback) {
            actions.push(<Divider key="modify-unit-divider" />);
        }

        if (editActionCallback) {
            actions.push(
                <DropdownItem key="edit" onClick={() => editActionCallback()}>{_("Edit")}</DropdownItem>
            );
        }

        if (deleteActionCallback) {
            actions.push(
                <DropdownItem key="delete" className="pf-m-danger"
                              onClick={() => deleteActionCallback()}>{_("Delete")}</DropdownItem>
            );
        }

        if (actions.length > 0) {
            actions.push(
                <Divider key="divider" />
            );
        }

        if (failed)
            actions.push(
                <DropdownItem key="reset" onClick={() => actionCallback("ResetFailedUnit", []) }>{ _("Clear 'Failed to start'") }</DropdownItem>
            );

        const confirm = () => {
            Dialogs.show(<ServiceConfirmDialog id="mask-service"
                                               title={ _("Mask service") }
                                               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.")}
                                               confirmText={ _("Mask service") }
                                               confirmAction={() => {
                                                   fileActionCallback("MaskUnitFiles", false);
                                                   if (failed)
                                                       actionCallback("ResetFailedUnit", []);
                                                   Dialogs.close();
                                               }} />);
        };

        actions.push(
            <DropdownItem key="mask" onClick={confirm}>{ _("Disallow running (mask)") }</DropdownItem>
        );

        actions.push(<Divider key="pin-divider" />);
        actions.push(
            <DropdownItem key="pin" onClick={() => pinUnitCallback() }>{isPinned ? _("Unpin unit") : _("Pin unit")}</DropdownItem>
        );
    }

    return (
        <KebabDropdown id="service-actions" dropdownItems={actions} isDisabled={disabled} title={ _("Additional actions") } />
    );
};

/*
 * React template for a service details
 * Shows current status and information about the service.
 * Enables user to control this unit like starting, enabling, etc. the service.
 * Required props:
 *  -  unit
 *      as returned from systemd org.freedesktop.systemd1.{Unit,Socket}
 *      D-Bus interface, but with unwrapped variants, and with additional "path"
 *      property and addTimerProperties()
 *  -  permitted
 *      True if user can control this unit
 *  -  systemdManager
 *      Callback for displaying errors
 *  -  isValid
 *      Method for finding if unit is valid
 */
export class ServiceDetails extends React.Component {
    static contextType = DialogsContext;

    constructor(props) {
        super(props);

        this.state = {
            waitsAction: false,
            waitsFileAction: false,
            unit_properties: {},
            showDeleteDialog: false,
            unitPaths: [],
            cockpitManaged: false,
            isPinned: this.props.pinnedUnits.includes(this.props.unit.Id),
        };

        this.onOnOffSwitch = this.onOnOffSwitch.bind(this);
        this.unitAction = this.unitAction.bind(this);
        this.unitFileAction = this.unitFileAction.bind(this);
        this.editTimerAction = this.editTimerAction.bind(this);
        this.deleteAction = this.deleteAction.bind(this);
        this.deleteTimer = this.deleteTimer.bind(this);
        this.pinUnit = this.pinUnit.bind(this);

        this.unitType = props.unit.Id.split('.').slice(-1)[0];
        this.unitTypeCapitalized = this.unitType.charAt(0).toUpperCase() + this.unitType.slice(1);
        this.doMemoryCurrentPolling = this.doMemoryCurrentPolling.bind(this);

        // MemoryCurrent property does not emit a changed signal - do polling for this property
        if (props.unit.ActiveState === "active") {
            this.doMemoryCurrentPolling();
            this.interval = setInterval(this.doMemoryCurrentPolling, METRICS_POLL_DELAY);
        }
    }

    componentDidMount() {
        if (this.props.unit.FragmentPath?.startsWith("/etc/systemd")) {
            cockpit.file(this.props.unit.FragmentPath)
                    .read()
                    .then(content => {
                        if (content?.startsWith(CockpitManagedMarker)) {
                            this.setState({ cockpitManaged: true });
                        }
                    });
        }
    }

    componentDidUpdate(prevProps) {
        // If unit became active start property polling and if got inactive stop
        if (this.props.unit.ActiveState === 'active' && !this.interval) {
            this.doMemoryCurrentPolling();
            this.interval = setInterval(this.doMemoryCurrentPolling, METRICS_POLL_DELAY);
        }
        if (this.props.unit.ActiveState === 'inactive' && this.interval) {
            this.doMemoryCurrentPolling();
            clearInterval(this.interval);
        }
    }

    componentWillUnmount() {
        if (this.interval)
            clearInterval(this.interval);
    }

    static getDerivedStateFromProps(nextProps, prevState) {
        return {
            waitsAction: nextProps.loadingUnits,
            waitsFileAction: nextProps.loadingUnits,
        };
    }

    show_note(note) {
        const Dialogs = this.context;
        Dialogs.show(<ServiceConfirmDialog title={_("Note")} message={note} />);
    }

    show_error(error) {
        const Dialogs = this.context;
        Dialogs.show(<ServiceConfirmDialog title={_("Error")} message={error} />);
    }

    doMemoryCurrentPolling() {
        systemd_client[this.props.owner].call(this.props.unit.path,
                                              "org.freedesktop.DBus.Properties", "Get",
                                              ["org.freedesktop.systemd1." + this.unitTypeCapitalized, 'MemoryCurrent'])
                .then(result => {
                    this.addUnitProperties(
                        "MemoryCurrent",
                        result[0] && result[0].v > 0 && result[0].v < MAX_UINT64 ? result[0].v : null,
                    );
                }, ex => console.log(ex.message));
    }

    addUnitProperties(prop, value) {
        if (prop === "MemoryCurrent" && this.state.unit_properties.MemoryCurrent !== value)
            this.setState(prevState => ({ unit_properties: { ...prevState.unit_properties, [prop]: value } }));
    }

    onOnOffSwitch() {
        if (this.props.unit.UnitFileState === "enabled") {
            let promise;
            if (this.props.unit.ActiveState === "active" || this.props.unit.ActiveState === "activating")
                promise = this.unitAction("StopUnit");
            else if (this.props.unit.ActiveState === "failed")
                promise = this.unitAction("ResetFailedUnit", []);
            else
                promise = Promise.resolve();

            promise.then(() => this.unitFileAction("DisableUnitFiles", undefined));
        } else {
            this.unitFileAction("EnableUnitFiles", false)
                    .then(() => {
                        if (this.props.unit.ActiveState !== "active" && this.props.unit.ActiveState !== "activating")
                            this.unitAction("StartUnit");
                    });
        }
    }

    unitAction(method, extra_args, catchExc = true) {
        if (extra_args === undefined)
            extra_args = ["fail"];
        this.setState({ waitsAction: true });
        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));
        if (catchExc) {
            return promise.catch(error => {
                this.show_error(error.toString());
                this.setState({ waitsAction: false });
            });
        } else {
            return promise;
        }
    }

    pinUnit() {
        const newPinned = this.state.isPinned
            ? this.props.pinnedUnits.filter(unitId => unitId !== this.props.unit.Id)
            : [...this.props.pinnedUnits, this.props.unit.Id];

        localStorage.setItem('systemd:pinnedUnits', JSON.stringify(newPinned));
        this.setState(prevState => ({ isPinned: !prevState.isPinned }));
        dispatchEvent(new Event('storage'));
    }

    unitFileAction(method, force, catchExc = true) {
        this.setState({ waitsFileAction: true });
        const args = [[this.props.unit.Names[0]], false];
        if (force !== undefined)
            args.push(force === "true");
        const promise = systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, method, args)
                .then(([results]) => {
                    if (results.length === 2 && !results[0])
                        this.show_note(_("This unit is not designed to be enabled explicitly."));
                    /* Executing daemon reload after file operations is necessary -
                     * see https://github.com/systemd/systemd/blob/main/src/systemctl/systemctl.c [enable_unit function]
                     */
                    return systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, "Reload", null);
                });
        if (catchExc) {
            return promise.catch(error => {
                this.show_error(error.toString());
                this.setState({ waitsFileAction: false });
            });
        } else {
            return promise;
        }
    }

    editTimerAction() {
        async function getCommand(bus, serviceUnitName) {
            const serviceDbusPath = await bus.call(s_bus.O_MANAGER, s_bus.I_MANAGER, "LoadUnit", [serviceUnitName]);
            const serviceProperties = await bus.call(serviceDbusPath[0], s_bus.I_PROPS, "GetAll", [s_bus.I_SERVICE]);
            const exec = serviceProperties[0].ExecStart.v;

            // ensure there is exactly one ExecStart= in the unit file that matches "/bin/sh -c CMD"

            if (exec.length !== 1) {
                console.warn(`${exec.length} ExecStart= entries were found for ${serviceUnitName} instead of 1`);
                return null;
            }

            const [cmd, args] = exec[0];
            if (cmd !== "/bin/sh" || args.length !== 3 || args[1] !== "-c") {
                console.warn(`ExecStart= entry is not of the form "/bin/sh -c CMD"`);
                return null;
            }

            return args[2];
        }

        const Dialogs = this.context;

        const timerName = this.props.unit.Id.slice(0, this.props.unit.Id.lastIndexOf("."));
        const serviceUnitName = timerName + ".service"; // timers created with cockpit only trigger one service

        const timerSettings = {
            name: timerName,
            description: this.props.unit.Description
        };

        let conditions = null;
        if (this.props.unit.TimersMonotonic.length > 0) {
            conditions = from_boot_usec(this.props.unit.TimersMonotonic[0][1]);
        } else if (this.props.unit.TimersCalendar.length > 0) {
            conditions = from_on_calendar(this.props.unit.TimersCalendar.map(item => item[1]));
        }

        getCommand(systemd_client[this.props.owner], serviceUnitName).then(command => {
            const timer = { ...timerSettings, ...conditions, command };
            Dialogs.show(<TimerDialog owner={this.props.owner} timer={timer} />);
        });
    }

    deleteAction() {
        this.getUnitPaths().then(unitPaths => {
            this.setState({ showDeleteDialog: true, unitPaths });
        });
    }

    async getUnitPaths() {
        const paths = [this.props.unit.FragmentPath];

        await Promise.all(this.props.unit.Triggers.map(async trigger => {
            // Getting dbus properties from a non-loaded unit is not possible so resort to systemctl show
            const unitPath = await cockpit.spawn(["systemctl", "show", "--value",
                "--property", "FragmentPath", trigger]);
            paths.push(unitPath.trim());
        })).catch(err => console.error("failed to look up unit details:", err.toString()));

        return paths;
    }

    deleteTimer() {
        // Stop timer so we don't get race conditions when the unit is gone.
        if (this.interval) {
            clearInterval(this.interval);
            this.interval = null;
        }

        const promises = [];
        if (this.props.unit.ActiveState === "active" || this.props.unit.ActiveState === "activating")
            promises.push(this.unitAction("StopUnit", undefined, false));
        if (this.props.unit.ActiveState === "failed")
            promises.push(this.unitAction("ResetFailedUnit", undefined, false));
        if (this.props.unit.UnitFileState === "enabled")
            promises.push(this.unitFileAction("DisableUnitFiles", undefined, false));

        return Promise.all(promises).then(() => {
            const deletions = this.state.unitPaths.filter(path => path.startsWith("/etc/systemd/system"))
                    .map(path => cockpit.file(path, { superuser: "require" }).replace(null));

            // Reload after unit/timer removal
            return Promise.all(deletions).then(() =>
                systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, "Reload", null)
                        .then(() => cockpit.jump("/system/services#/?type=timer"))
            );
        });
    }

    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";
        const unit = this.state.unit_properties;
        const showAction = this.props.permitted || this.props.owner === "user";
        const isCustom = this.props.unit.FragmentPath.startsWith("/etc/systemd/system") && !masked;
        const isTimer = (this.unitType === "timer");
        const isQuadlet = this.props.unit.SourcePath?.includes("/containers/systemd/");

        let status = [];

        if (masked) {
            status.push(
                <div key="masked" className="status-masked">
                    <Icon>
                        <BanIcon className="status-icon" />
                    </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">
                    <Icon>
                        <OffIcon className="status-icon" />
                    </Icon>
                    <span className="status">{ _("Disabled") }</span>
                </div>
            );
        }

        if (failed) {
            status.push(
                <div key="failed" className="status-failed">
                    <Icon status="danger">
                        <ErrorCircleOIcon className="status-icon" />
                    </Icon>
                    <span className="status">{ _("Failed to start") }</span>
                    { showAction &&
                    <Button variant="secondary" className="action-button" onClick={() => this.unitAction("StartUnit") }>{ _("Start service") }</Button>
                    }
                </div>
            );
        }

        if (!status.length) {
            if (active) {
                status.push(
                    <div key="running" className="status-running">
                        <Icon status="success">
                            <OnRunningIcon className="status-icon" />
                        </Icon>
                        <span className="status">{ _("Running") }</span>
                        <span className="side-note font-xs">{ _("Active since ") + timeformat.dateTime(this.props.unit.ActiveEnterTimestamp / 1000) }</span>
                    </div>
                );
            } else {
                status.push(
                    <div key="stopped" className="status-stopped">
                        <Icon>
                            <OffIcon className="status-icon" />
                        </Icon>
                        <span className="status">{ _("Not running") }</span>
                    </div>
                );
            }
        }

        if (isStatic && !masked) {
            status.unshift(
                <div key="static" className="status-static">
                    <Icon>
                        <AsleepIcon className="status-icon" />
                    </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 (!showAction && this.props.owner !== 'user') {
            status.unshift(
                <div key="readonly" className="status-readonly">
                    <Icon>
                        <UserIcon className="status-icon" />
                    </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">
                    <Icon status="success">
                        <OkIcon className="status-icon" />
                    </Icon>
                    <span className="status">{ _("Automatically starts") }</span>
                </div>
            );
        }

        if (this.props.unit.NextRunTime || this.props.unit.LastTriggerTime) {
            status.push(
                <div className="service-unit-triggers" key="triggers">
                    {this.props.unit.NextRunTime && <div className="service-unit-next-trigger">{cockpit.format("Next run: $0", this.props.unit.NextRunTime)}</div>}
                    {this.props.unit.LastTriggerTime && <div className="service-unit-last-trigger">{cockpit.format("Last trigger: $0", this.props.unit.LastTriggerTime)}</div>}
                </div>
            );
        }

        /* If there is some ongoing action just show spinner */
        if (this.state.waitsAction || this.state.waitsFileAction) {
            status = [
                <div key="updating" className="status-updating">
                    <Icon>
                        <Spinner size="md" className="status-icon" />
                    </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";

        if (hasLoadError) {
            const path = "/system/services" + (this.props.owner === "user" ? "#/?owner=user" : ""); // not-covered: OS error
            const loadError = this.props.unit.LoadError ? this.props.unit.LoadError[1] : null; // not-covered: OS error
            const title = loadError || _("Failed to load unit"); // not-covered: OS error

            return <EmptyStatePanel
                icon={ExclamationCircleIcon}
                title={title}
                paragraph={this.props.unitId}
                action={_("View all services")}
                actionVariant="link"
                onAction={() => cockpit.jump(path, cockpit.transport.host)}
            />;
        }

        // These are relevant for socket and timer activated services
        const triggerRelationships = [
            { Name: _("Triggers"), Units: this.props.unit.Triggers },
            { Name: _("Triggered by"), Units: this.props.unit.TriggeredBy },
        ];

        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: _("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 relationshipsToList = rels => {
            return rels.filter(rel => rel.Units && rel.Units.length > 0)
                    .map(rel =>
                        <DescriptionListGroup key={rel.Name}>
                            <DescriptionListTerm>{rel.Name}</DescriptionListTerm>
                            <DescriptionListDescription id={rel.Name.split(" ").join("")}>
                                <ul className="comma-list">
                                    {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>)}
                                </ul>
                            </DescriptionListDescription>
                        </DescriptionListGroup>
                    );
        };

        const triggerRelationshipsList = relationshipsToList(triggerRelationships);

        const extraRelationshipsList = relationshipsToList(relationships);

        const conditions = this.props.unit.Conditions;
        const notMetConditions = [];
        if (conditions)
            conditions.forEach(condition => {
                if (condition[4] < 0)
                    notMetConditions.push(cockpit.format(_("Condition $0=$1 was not met"), condition[0], condition[3]));
            });

        return (
            <Card isPlain id="service-details-unit" className="ct-card">
                { this.state.showDeleteDialog &&
                <DeleteModal
                    name={this.props.unit.Description}
                    handleCancel={() => this.setState({ showDeleteDialog: false })}
                    handleDelete={this.deleteTimer}
                    reason={<Flex>
                        <p>{_("Deletion will remove the following files:")}</p>
                        <List>
                            {this.state.unitPaths.map(item => <ListItem key={item}>{item}</ListItem>)}
                        </List>
                    </Flex>
                    }
                />
                }
                <CardHeader>
                    <Flex className="service-top-panel" spaceItems={{ default: 'spaceItemsMd' }} alignItems={{ default: 'alignItemsCenter' }}>
                        <CardTitle component="h2" className="service-name">{this.props.unit.Description}</CardTitle>
                        {this.state.isPinned &&
                        <Tooltip content={_("Pinned unit")}>
                            <ThumbtackIcon className='service-thumbtack-icon' />
                        </Tooltip>}
                        { showAction &&
                            <>
                                { !masked && !isStatic &&
                                    <Tooltip id="switch-unit-state" content={tooltipMessage} position={TooltipPosition.right}>
                                        <Switch isChecked={enabled}
                                                aria-label={tooltipMessage}
                                                isDisabled={this.state.waitsAction || this.state.waitsFileAction}
                                                onChange={this.onOnOffSwitch} />
                                    </Tooltip>
                                }
                                <ServiceActions { ...{ active, failed, enabled, masked } } canReload={this.props.unit.CanReload}
                                                actionCallback={this.unitAction} fileActionCallback={this.unitFileAction}
                                                editActionCallback={isCustom && isTimer && this.state.cockpitManaged ? this.editTimerAction : null}
                                                deleteActionCallback={isCustom && isTimer ? this.deleteAction : null}
                                                disabled={this.state.waitsAction || this.state.waitsFileAction}
                                                isPinned={this.state.isPinned} pinUnitCallback={this.pinUnit} />
                            </>
                        }
                    </Flex>
                </CardHeader>
                <CardBody>
                    <Stack hasGutter>
                        <DescriptionList isHorizontal>
                            <DescriptionListGroup>
                                <DescriptionListTerm>{ _("Status") }</DescriptionListTerm>
                                <DescriptionListDescription id="statuses">
                                    { status }
                                </DescriptionListDescription>
                            </DescriptionListGroup>
                            <DescriptionListGroup>
                                <DescriptionListTerm>{ _("Path") }</DescriptionListTerm>
                                <DescriptionListDescription id="path">{this.props.unit.FragmentPath}</DescriptionListDescription>
                            </DescriptionListGroup>
                            {unit.MemoryCurrent
                                ? <DescriptionListGroup>
                                    <DescriptionListTerm>{ _("Memory") }</DescriptionListTerm>
                                    <DescriptionListDescription id="memory">{cockpit.format_bytes(unit.MemoryCurrent)}</DescriptionListDescription>
                                </DescriptionListGroup>
                                : null}
                            {this.props.unit.Listen && this.props.unit.Listen.length && <DescriptionListGroup>
                                <DescriptionListTerm>{ _("Listen") }</DescriptionListTerm>
                                <DescriptionListDescription id="listen">
                                    {cockpit.format("$0 ($1)", this.props.unit.Listen[0][1], this.props.unit.Listen[0][0])}
                                </DescriptionListDescription>
                            </DescriptionListGroup>}
                            {isQuadlet && cockpit.manifests?.podman?.capabilities?.includes("service-filtering") && <DescriptionListGroup>
                                <DescriptionListTerm>{ _("Container") }</DescriptionListTerm>
                                <DescriptionListDescription id="container">
                                    <Button variant="link" isInline onClick={
                                        () => cockpit.jump(`/podman#/?service=${this.props.unit.Id}`)}>
                                        {_("View Podman container")}
                                    </Button>
                                </DescriptionListDescription>
                            </DescriptionListGroup>}
                            { notMetConditions.length > 0 &&
                                <DescriptionListGroup>
                                    <DescriptionListTerm className="failed">{ _("Condition failed") }</DescriptionListTerm>
                                    <DescriptionListDescription id="condition">
                                        {notMetConditions.map(cond => <div key={cond}>{cond}</div>)}
                                    </DescriptionListDescription>
                                </DescriptionListGroup>
                            }
                            {triggerRelationshipsList}
                        </DescriptionList>
                        {extraRelationshipsList.length
                            ? <ExpandableSection id="service-details-show-relationships" toggleText={triggerRelationshipsList.length ? _("Show more relationships") : _("Show relationships")}>
                                <DescriptionList isHorizontal>
                                    {extraRelationshipsList}
                                </DescriptionList>
                            </ExpandableSection>
                            : null}
                    </Stack>
                </CardBody>
            </Card>
        );
    }
}

const DeleteModal = ({ reason, name, handleCancel, handleDelete }) => {
    const [inProgress, setInProgress] = useState(false);
    const [dialogError, setDialogError] = useState(undefined);
    return (
        <Modal isOpen
               position="top" variant="medium"
               onClose={handleCancel}
        >
            <ModalHeader title={cockpit.format(_("Confirm deletion of $0"), name)}
                titleIconVariant="warning"
            />
            <ModalBody>
                <Stack hasGutter>
                    {dialogError && <ModalError dialogError={_("Timer deletion failed")} dialogErrorDetail={dialogError} />}
                    {reason}
                </Stack>
            </ModalBody>
            <ModalFooter>
                <Button id="delete-timer-modal-btn" variant="danger" isDisabled={inProgress} isLoading={inProgress}
                        onClick={() => { setInProgress(true); handleDelete().catch(exc => { setDialogError(exc.message); setInProgress(false) }) }}
                >
                    {_("Delete")}
                </Button>
                <Button variant="link" isDisabled={inProgress} onClick={handleCancel}>{_("Cancel")}</Button>
            </ModalFooter>
        </Modal>
    );
};