LCOV - code coverage report
Current view: top level - pkg/systemd - services.jsx Coverage Total Hit
Test: cockpit Lines: 90.2 % 650 586
Test Date: 2026-07-03 14:25:32

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2020 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6              : import '../lib/patternfly/patternfly-6-cockpit.scss';
       7              : import 'polyfills'; // once per application
       8              : import 'cockpit-dark-theme'; // once per page
       9              : 
      10           33 : import React, { useState, useEffect } from "react";
      11           33 : import { createRoot } from 'react-dom/client';
      12              : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
      13              : import { Page, PageSection, } from "@patternfly/react-core/dist/esm/components/Page/index.js";
      14              : import { Card, CardHeader } from "@patternfly/react-core/dist/esm/components/Card/index.js";
      15              : import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchInput/index.js";
      16              : import { ToggleGroup, ToggleGroupItem } from "@patternfly/react-core/dist/esm/components/ToggleGroup/index.js";
      17              : import { Toolbar, ToolbarContent, ToolbarFilter, ToolbarItem, ToolbarToggleGroup } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js";
      18              : import { ExclamationCircleIcon, FilterIcon } from '@patternfly/react-icons';
      19              : 
      20              : import { CheckboxSelect } from "cockpit-components-checkbox-select";
      21              : import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
      22              : import { Service } from "./service.jsx";
      23              : import { ServiceTabs, service_tabs_suffixes } from "./service-tabs";
      24              : import { ServicesList } from "./services-list.jsx";
      25              : import { CreateTimerDialogButton } from "./timer-dialog.jsx";
      26              : import { page_status } from "notifications";
      27              : import * as python from "python";
      28              : import * as timeformat from "timeformat";
      29           33 : import cockpit from "cockpit";
      30              : import { superuser } from 'superuser';
      31              : import { useEvent, usePageLocation } from "hooks";
      32              : import { WithDialogs } from "dialogs.jsx";
      33              : 
      34              : import s_bus from "./busnames.js";
      35              : import "./services.scss";
      36              : 
      37           33 : const _ = cockpit.gettext;
      38              : 
      39              : // As long as we have long-running superuser channels, we need to
      40              : // reload the page when the access level changes.
      41              : //
      42           33 : superuser.reload_page_on_change();
      43              : 
      44           33 : export const systemd_client = {
      45           33 :     system: cockpit.dbus(s_bus.BUS_NAME, { bus: "system", superuser: "try" }),
      46           33 :     user: cockpit.dbus(s_bus.BUS_NAME, { bus: "session" }),
      47           33 : };
      48           33 : const timedate_client = cockpit.dbus('org.freedesktop.timedate1');
      49           33 : export let clock_realtime_now = 0; // ms since epoch; call updateTime() before using
      50           33 : let monotonic_timer_base = null; // µs
      51              : 
      52           33 : export const MAX_UINT64 = 2 ** 64 - 1;
      53              : 
      54           33 : function debug() {
      55            3 :     if (window.debugging == "all" || window.debugging?.includes("services"))
      56            3 :         console.debug.apply(console, arguments);
      57           33 : }
      58              : 
      59           33 : export function updateTime() {
      60              :     // To correctly interpret monotonic timers, we need to read CLOCK_MONOTONIC. This cannot be read with shell tools
      61           33 :     python.spawn("import time; print(int(time.clock_gettime(time.CLOCK_REALTIME) * 1000000), int(time.clock_gettime(time.CLOCK_MONOTONIC) * 1000000))",
      62           33 :                  null, { err: "message" })
      63           33 :             .then(output => {
      64           33 :                 const [realtime_us, monotonic_us] = output.split(' ').map(t => parseInt(t));
      65           33 :                 clock_realtime_now = realtime_us / 1000;
      66           33 :                 monotonic_timer_base = realtime_us - monotonic_us;
      67           33 :                 debug("Read clocks with Python; realtime", clock_realtime_now, "monotonic base", monotonic_timer_base);
      68           33 :             })
      69            0 :             .catch(ex => {
      70              :                 /* If Python is not available, fall back to reading CLOCK_BOOTTIME from /proc/timer-list.
      71              :                  * This is readable by root only */
      72            0 :                 console.log("Failed to read clocks with Python, using fallback:", ex.toString());
      73            0 :                 Promise.allSettled([
      74            0 :                     cockpit.spawn(["date", "+%s"]),
      75            0 :                     cockpit.file("/proc/timer_list", { superuser: "try" }).read(),
      76            0 :                 ])
      77            0 :                         .then(([date_result, timer_list_result]) => {
      78            0 :                             if (date_result.status === "fulfilled") {
      79            0 :                                 clock_realtime_now = parseInt(date_result.value) * 1000;
      80            0 :                             } else {
      81            0 :                                 console.warn("Failed to read realtime clock:", date_result.reason.toString());
      82            0 :                                 return;
      83            0 :                             }
      84              : 
      85              :                             // third line is "now at N nsecs"
      86            0 :                             if (timer_list_result.status === "fulfilled") {
      87            0 :                                 const match = /now at (\d+) nsecs/.exec(timer_list_result.value);
      88            0 :                                 if (match)
      89            0 :                                     monotonic_timer_base = clock_realtime_now * 1000 - parseInt(match[1]) / 1000;
      90              : 
      91            0 :                                 debug("Read clocks with fallback; realtime", clock_realtime_now, "monotonic base", monotonic_timer_base);
      92            0 :                             } else {
      93            0 :                                 console.log("Failed to read /proc/timer_list:", timer_list_result.reason.toString());
      94            0 :                             }
      95            0 :                         });
      96            0 :             });
      97           33 : }
      98              : 
      99              : /* Notes about the systemd D-Bus API
     100              :  *
     101              :  * - Loading all units, fetching their properties, and listening to JobNew/JobRemoved is
     102              :  *   expensive, so the services list does not do that. For 90% of what the list shows we
     103              :  *   only need two calls: ListUnits() for enough information about all units which are in
     104              :  *   systemd's brain; and ListUnitFiles() to add the inert ones (disabled, stopped, not a
     105              :  *   dependency of anything). The only exception are timers, where we have to get the
     106              :  *   properties of the Timers interface to show their last and next run. This information
     107              :  *   is collected in listUnits().
     108              :  *
     109              :  * - To keep up with changes, we listen to two signals: PropertiesChanged (which also gets
     110              :  *   fired when a unit gets loaded) for run state chanes, and Reloading for file state
     111              :  *   changes (like enabling/disabling).
     112              :  *
     113              :  * - When loading an unloaded unit, PropertiesChanged will be fired, but unfortunately in a
     114              :  *   rather useless way: it does not contain all properties (thus it needs a GetAll),
     115              :  *   and it usually happens for the Timer interface first (when we don't yet have an ID) and
     116              :  *   for the Unit interface later; due to that, we track them in two separate dicts.
     117              :  *
     118              :  * - The unit details view does its own independent API communication and state
     119              :  *   management. It needs to fetch/interpret a lot of unit properties which are not part
     120              :  *   of ListUnits(), but it only needs to do that for a single unit.
     121              :  *
     122              :  * - ListUnitFiles will return unit files that are aliases for other unit files, but
     123              :  *   ListUnits will not return aliases.
     124              :  *
     125              :  * - Methods like EnableUnitFiles only change the state of files on disk.  A Reload is
     126              :  *   necessary to update the state of loaded units.
     127              :  *
     128              :  * - The unit file state as returned by ListUnitFiles is not necessarily the same as the
     129              :  *   UnitFileState property of a loaded unit. ListUnitFiles reflects the state of the
     130              :  *   files on disk, while a loaded unit is only updated to that state via an explicit
     131              :  *   Reload. Thus, be careful to only use the UnitFileState as returned by ListUnitFiles
     132              :  *   for unloaded units. Loaded units should use the PropertiesChanged value to reflect
     133              :  *   runtime reality.
     134              :  *
     135              :  * A few historical notes which don't apply to the current code, but could be useful in
     136              :  * the future:
     137              :  *
     138              :  *
     139              :  * - A unit that isn't currently loaded has no object path. If you need one, do
     140              :  *   LoadUnit(); doing so will emit UnitNew.
     141              :  *
     142              :  * - One can use an object path for a unit that isn't currently loaded. Doing so will load
     143              :  *   the unit (and emit UnitNew).
     144              :  *
     145              :  * - JobNew and JobRemoved signals don't include the object path of the affected units,
     146              :  *   but we can get those by listening to UnitNew.
     147              :  *
     148              :  * - There might be UnitNew signals for units that are never returned by ListUnits or
     149              :  *   ListUnitFiles.  These are units that are mentioned in Requires, After, etc or that
     150              :  *   people try to load via LoadUnit but that don't actually exist.
     151              :  *
     152              :  * - The "Names" property of a unit only includes those aliases that are currently loaded,
     153              :  *   not all.  To get all possible aliases, one needs to call ListUnitFiles and match
     154              :  *   units via their object path.
     155              :  *
     156              :  * - The unit file state of a alias as returned by ListUnitFiles is always the same as the
     157              :  *   unit file state of the primary unit file.
     158              :  *
     159              :  * - A Reload will emit UnitRemoved/UnitNew signals for all units, and no
     160              :  *   PropertiesChanges signal for the properties that have changed because of the reload,
     161              :  *   such as UnitFileState.
     162              :  */
     163              : 
     164           33 : class ServicesPageBody extends React.Component {
     165           33 :     constructor(props) {
     166           33 :         super(props);
     167           33 :         this.state = {
     168              :             /* State related to the toolbar components */
     169           33 :             isFullyLoaded: false,
     170           33 :             error: null,
     171           33 :             pinnedUnits: [],
     172           33 :         };
     173              : 
     174              :         /* data storage
     175              :          *
     176              :          * do not keep as state, as that requires too much copying, and it's easy to miss updates due to setState()
     177              :          * coalescing; whenever these change, you need to force a state update to re-render
     178              :          */
     179              : 
     180              :         /* loaded units: ListUnits()/PropertiesChanged for Unit interface; object path → {
     181              :                Id,
     182              :                Description, LoadState, ActiveState,
     183              :                UnitFileState, // if unit has a file and got a PropertiesChanged
     184              :            } */
     185           33 :         this.units = {};
     186              :         // for <Service unitIsValid >
     187           33 :         this.knownIds = new Set();
     188              : 
     189              :         // active timers: object path → { LastTriggerTime, NextRunTime } (formatted strings)
     190              :         // lazily initialized when actually showing the Timers tab
     191           33 :         this.timers = null;
     192              : 
     193              :         /* ListUnitFiles() result; updated with daemon reload
     194              :            name/id (e.g. foo.service) → { Id, UnitFileState, ActiveState ("inactive" or empty for aliases) } */
     195           33 :         this.unit_files = {};
     196              : 
     197              :         // other state which should not cause re-renders
     198           33 :         this.seenActiveStates = new Set();
     199           33 :         this.seenUnitFileStates = new Set();
     200           33 :         this.reloading = false;
     201              : 
     202              :         // Possible LoadState values: stub, loaded, not-found, bad-setting, error, merged, masked
     203              :         // See: typedef enum UnitLoadStateState https://github.com/systemd/systemd/blob/main/src/basic/unit-def.h
     204           33 :         this.loadState = {
     205           33 :             stub: _("Stub"),
     206           33 :             loaded: "",
     207           33 :             "not-found": _("Not found"),
     208           33 :             "bad-setting": _("Bad setting"),
     209           33 :             error: _("Error"),
     210           33 :             merged: _("Merged"),
     211           33 :             masked: "", // We present the masked from the unitFileState
     212           33 :         };
     213              : 
     214              :         // Possible ActiveState values: active, reloading, inactive, failed, activating, deactivating, maintenance
     215              :         // See: typedef enum UnitActiveState https://github.com/systemd/systemd/blob/main/src/basic/unit-def.h
     216           33 :         this.activeState = {
     217           33 :             active: _("Running"),
     218           33 :             reloading: _("Reloading"),
     219           33 :             inactive: _("Not running"),
     220           33 :             failed: _("Failed to start"),
     221           33 :             activating: _("Running"),
     222           33 :             deactivating: _("Not running"),
     223           33 :             maintenance: _("Maintenance"),
     224           33 :         };
     225              : 
     226              :         // Possible UnitFileState values: enabled, enabled-runtime, linked, linked-runtime, alias, masked, masked-runtime, static, disabled, invalid, indirect, generated, transient, bad
     227              :         // See: typedef enum UnitFileState https://github.com/systemd/systemd/blob/main/src/shared/unit-file.h
     228           33 :         this.unitFileState = {
     229           33 :             enabled: _("Enabled"),
     230           33 :             "enabled-runtime": _("Enabled"),
     231           33 :             disabled: _("Disabled"),
     232           33 :             linked: _("Linked"),
     233           33 :             "linked-runtime": _("Linked"),
     234           33 :             alias: _("Alias"),
     235           33 :             masked: _("Masked"),
     236           33 :             "masked-runtime": _("Masked"),
     237           33 :             static: _("Static"),
     238           33 :             invalid: _("Invalid"),
     239           33 :             indirect: _("Indirect"),
     240           33 :             generated: _("Generated"),
     241           33 :             transient: _("Transient"),
     242           33 :             bad: _("Bad"),
     243           33 :         };
     244              : 
     245           33 :         this.listUnits = this.listUnits.bind(this);
     246           33 :         this.loadPinnedUnits = this.loadPinnedUnits.bind(this);
     247           33 :         this.onOptionsChanged = this.onOptionsChanged.bind(this);
     248           33 :         this.compareUnits = this.compareUnits.bind(this);
     249           33 :         this.addTimerPropertiesFull = this.addTimerPropertiesFull.bind(this);
     250           33 :     }
     251              : 
     252            1 :     onOptionsChanged(options) {
     253            1 :         const currentOptions = { ...cockpit.location.options, ...options };
     254              : 
     255            1 :         if (!currentOptions.activestate || options.activestate == "[]")
     256            1 :             delete currentOptions.activestate;
     257              : 
     258            1 :         if (!currentOptions.filestate || options.filestate == "[]")
     259            1 :             delete currentOptions.filestate;
     260              : 
     261            1 :         if (!currentOptions.name)
     262            1 :             delete currentOptions.name;
     263              : 
     264            1 :         cockpit.location.replace(cockpit.location.path, currentOptions);
     265            1 :     }
     266              : 
     267           33 :     componentDidMount() {
     268           33 :         systemd_client[this.props.owner].wait(() => {
     269           33 :             this.systemd_subscription = systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, "Subscribe", null)
     270           33 :                     .finally(this.listUnits)
     271            2 :                     .catch(error => {
     272            2 :                         if (error.name != "org.freedesktop.systemd1.AlreadySubscribed" &&
     273            2 :                         error.name != "org.freedesktop.DBus.Error.FileExists")
     274            2 :                             this.setState({ error: cockpit.format(_("Subscribing to systemd signals failed: $0"), error.toString()) });
     275            2 :                     });
     276           33 :         })
     277            0 :                 .catch(ex => this.setState({ error: cockpit.format(_("Connecting to dbus failed: $0"), ex.toString()) }));
     278              : 
     279           10 :         cockpit.addEventListener("visibilitychange", () => {
     280            6 :             if (!cockpit.hidden) {
     281            6 :                 debug("visibilitychange to visible; fully loaded", this.state.isFullyLoaded);
     282              :                 /* If the page had only been fetched in the background we need to properly initialize the state now
     283              :                  * else just trigger an re-render since we are receiving signals while running in the background and
     284              :                  * we update the state but don't re-render
     285              :                  */
     286            6 :                 if (!this.state.isFullyLoaded)
     287            2 :                     this.listUnits();
     288              :                 else
     289            6 :                     this.setState({});
     290            6 :             } else {
     291           10 :                 debug("visibilitychange to hidden");
     292           10 :             }
     293           10 :         });
     294              : 
     295              :         /* Start listening to signals for updates
     296              :          * - when in the middle of reload mute all signals
     297              :          * - We don't need to listen to 'UnitFilesChanged' signal since every time we
     298              :          *   perform some file operation we do call Reload which issues 'Reloading' signal
     299              :          */
     300           33 :         systemd_client[this.props.owner].subscribe({
     301           33 :             interface: s_bus.I_PROPS,
     302           33 :             member: "PropertiesChanged"
     303           28 :         }, async (path, _iface, _signal, [iface, props]) => {
     304           28 :             if (this.props.isLoading || this.reloading)
     305           28 :                 return;
     306              : 
     307              :             // ignore uninteresting unit types
     308           28 :             if (!this.isUnitHandled(path))
     309           28 :                 return;
     310              : 
     311              :             // ignore when timers did not yet get shown
     312            4 :             if (iface === s_bus.I_TIMER && this.timers !== null) {
     313            4 :                 if (!this.timers[path])
     314            4 :                     this.timers[path] = {};
     315            4 :                 this.addTimerProperties(props, this.timers[path]);
     316            4 :                 debug("timer PropertiesChanged on", path, JSON.stringify(this.timers[path]));
     317            4 :                 return;
     318            4 :             }
     319              : 
     320              :             // ignore uninteresting interfaces
     321           28 :             if (iface !== s_bus.I_UNIT)
     322           28 :                 return;
     323              : 
     324           28 :             let unit = this.units[path];
     325              : 
     326           21 :             if (!unit) {
     327              :                 // this happens when starting an unloaded unit; unfortunately Units props is very incomplete, so we need a GetAll
     328           21 :                 debug("unit PropertiesChanged on previously unloaded unit", path);
     329           21 :                 try {
     330           20 :                     [props] = await systemd_client[this.props.owner].call(path, s_bus.I_PROPS, "GetAll", [s_bus.I_UNIT]);
     331            0 :                 } catch (ex) { // not-covered: OS error
     332            0 :                     console.warn("GetAll Unit for unknown unit", path, "failed:", ex.toString()); // not-covered: OS error
     333            0 :                     return; // not-covered: OS error
     334            0 :                 }
     335           20 :                 unit = {};
     336           20 :                 this.units[path] = unit;
     337           20 :             }
     338              : 
     339              :             // unwrap variants
     340           27 :             for (const prop of ["ActiveState", "LoadState", "Description", "Id", "UnitFileState"]) {
     341           27 :                 if (props[prop])
     342           27 :                     unit[prop] = props[prop].v;
     343           27 :             }
     344           27 :             this.knownIds.add(unit.Id);
     345           27 :             debug("unit PropertiesChanged on", path, "complete:", JSON.stringify(unit));
     346              : 
     347           27 :             this.processFailedUnits();
     348           27 :             this.setState({ });
     349           28 :         });
     350              : 
     351              :         // handle transient units
     352           29 :         systemd_client[this.props.owner].subscribe({ interface: s_bus.I_MANAGER, member: "UnitRemoved" }, (_path, _iface, _signal, [_id, objpath]) => {
     353              :             // during daemon reload we get tons of these, ignore
     354           29 :             if (this.reloading)
     355           29 :                 return;
     356              : 
     357            3 :             if (this.units[objpath]?.UnitFileState === 'transient') {
     358            3 :                 debug("UnitRemoved of transient", objpath);
     359            3 :                 this.knownIds.delete(this.units[objpath]?.Id);
     360            3 :                 delete this.units[objpath];
     361            3 :                 this.processFailedUnits();
     362            3 :                 this.setState({ });
     363            3 :             }
     364           29 :         });
     365              : 
     366           14 :         systemd_client[this.props.owner].subscribe({ interface: s_bus.I_MANAGER, member: "Reloading" }, (_path, _iface, _signal, [reloading]) => {
     367           14 :             this.reloading = reloading;
     368           14 :             debug("Reloading", reloading);
     369           14 :             if (!reloading && !this.props.isLoading)
     370           14 :                 this.listUnits();
     371           14 :         });
     372              : 
     373           33 :         addEventListener('storage', this.loadPinnedUnits);
     374           33 :         this.loadPinnedUnits();
     375              : 
     376           33 :         this.timedated_subscription = timedate_client.subscribe({
     377           33 :             path_namespace: "/org/freedesktop/timedate1",
     378           33 :             interface: s_bus.I_PROPS,
     379           33 :             member: "PropertiesChanged"
     380           33 :         }, updateTime);
     381           33 :         updateTime();
     382           33 :     }
     383              : 
     384           33 :     shouldComponentUpdate(nextProps, nextState) {
     385           33 :         if (cockpit.hidden)
     386            8 :             return false;
     387              : 
     388           32 :         return true;
     389           33 :     }
     390              : 
     391           33 :     loadPinnedUnits() {
     392           33 :         try {
     393           33 :             this.setState({ pinnedUnits: JSON.parse(localStorage.getItem('systemd:pinnedUnits')) || [] });
     394            3 :         } catch (err) {
     395            3 :             console.warn("exception while parsing systemd:pinnedUnits", err);
     396            3 :             this.setState({ pinnedUnits: [] });
     397            3 :         }
     398           33 :     }
     399              : 
     400              :     /**
     401              :       * Return if the unit ID or path specified by @name is handled
     402              :       */
     403           31 :     isUnitHandled(name) {
     404           31 :         return service_tabs_suffixes.some(suffix => name.endsWith(suffix));
     405           31 :     }
     406              : 
     407              :     /* When the page is running in the background, fetch only information about failed units
     408              :      * in order to update the 'Page Status'. */
     409            2 :     listFailedUnits() {
     410            2 :         return systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, "ListUnitsFiltered", [["failed"]])
     411            1 :                 .then(([failed]) => {
     412            1 :                     const units = {};
     413            0 :                     failed.forEach(([
     414            0 :                         Id, Description, LoadState, ActiveState, _substate, _followUnit, ObjectPath,
     415            0 :                         _is_job_queued, _job_type, _job_path
     416            0 :                     ]) => {
     417            0 :                         if (!this.isUnitHandled(Id))
     418            0 :                             return;
     419              : 
     420            0 :                         units[ObjectPath] = { Id, Description, LoadState, ActiveState };
     421            0 :                     });
     422              : 
     423            1 :                     this.units = units;
     424            1 :                     this.processFailedUnits();
     425            1 :                 })
     426            0 :                 .catch(ex => console.warn('ListUnitsFiltered failed: ', ex.toString())); // not-covered: OS error
     427            2 :     }
     428              : 
     429           29 :     isTemplate(id) {
     430           29 :         const tp = id.indexOf("@");
     431           29 :         const sp = id.lastIndexOf(".");
     432            9 :         return (tp != -1 && (tp + 1 == sp || tp + 1 == id.length));
     433           29 :     }
     434              : 
     435           33 :     listUnits() {
     436           33 :         if (cockpit.hidden)
     437            5 :             return this.listFailedUnits();
     438              : 
     439              :         // Reinitialize the state variables for the units
     440           32 :         this.props.setIsLoading(true);
     441              : 
     442           32 :         const dbus = systemd_client[this.props.owner];
     443           32 :         const units = {};
     444              : 
     445           32 :         Promise.all([
     446           32 :             dbus.call(s_bus.O_MANAGER, s_bus.I_MANAGER, "ListUnits", null),
     447           32 :             dbus.call(s_bus.O_MANAGER, s_bus.I_MANAGER, "ListUnitFiles", null)
     448           32 :         ])
     449           29 :                 .then(([[unitsResults], [unitFilesResults]]) => {
     450           29 :                     this.knownIds = new Set();
     451              : 
     452              :                     // ListUnits is the primary source of information
     453           29 :                     unitsResults.forEach(([
     454           29 :                         Id, Description, LoadState, ActiveState, _substate, _followUnit, ObjectPath,
     455           29 :                         _is_job_queued, _job_type, _job_path
     456           29 :                     ]) => {
     457           29 :                         if (!this.isUnitHandled(Id))
     458           29 :                             return;
     459              : 
     460              :                         // We should ignore 'not-found' units when setting the seenActiveStates
     461           29 :                         if (LoadState !== 'not-found')
     462           29 :                             this.seenActiveStates.add(ActiveState);
     463              : 
     464           29 :                         units[ObjectPath] = { Id, Description, LoadState, ActiveState };
     465           29 :                         this.knownIds.add(Id);
     466           29 :                     });
     467              : 
     468              :                     // unloaded, but available unit files
     469           29 :                     const unit_files = {};
     470           29 :                     unitFilesResults.forEach(([UnitFilePath, UnitFileState]) => {
     471           29 :                         const Id = UnitFilePath.split('/').pop();
     472           29 :                         if (!this.isUnitHandled(Id) || this.isTemplate(Id))
     473           29 :                             return;
     474              : 
     475           29 :                         this.seenUnitFileStates.add(UnitFileState);
     476              :                         // there is not enough information to link this to the primary unit which declared the alias
     477              :                         // name; that requires a LoadUnit() + Get("Names") + reverse lookup; the details page has the
     478              :                         // correct information, so skip the status for aliases
     479              :                         // for other units, we default to "inactive"; loaded units will override that
     480           29 :                         const ActiveState = (UnitFileState === "alias") ? undefined : "inactive";
     481           29 :                         unit_files[Id] = { Id, UnitFileState, ActiveState };
     482           29 :                     });
     483              : 
     484           29 :                     this.units = units;
     485           29 :                     this.unit_files = unit_files;
     486           29 :                     this.processFailedUnits();
     487           29 :                     this.setState({ isFullyLoaded: true });
     488           29 :                 })
     489            0 :                 .catch(ex => this.setState({ error: cockpit.format(_("Listing units failed: $0"), ex.toString()) })) // not-covered: OS error
     490           29 :                 .finally(() => this.props.setIsLoading(false));
     491           33 :     }
     492              : 
     493            4 :     loadTimers() {
     494            4 :         const dbus = systemd_client[this.props.owner];
     495            4 :         const promises = [];
     496            4 :         this.timers = {};
     497            4 :         debug("Loading all timers");
     498              : 
     499            4 :         Object.entries(this.units).forEach(([path, unit]) => {
     500            4 :             if (unit.Id.endsWith(".timer")) {
     501            4 :                 promises.push(dbus.call(path, s_bus.I_PROPS, "GetAll", [s_bus.I_TIMER])
     502            4 :                         .then(([props]) => {
     503            4 :                             this.timers[path] = {};
     504            4 :                             this.addTimerPropertiesFull(props, this.timers[path]);
     505            4 :                         })
     506            0 :                         .catch(ex => console.warn("Loading timer information for", path, "failed:", ex.toString()))); // not-covered: OS error
     507            4 :             }
     508            4 :         });
     509            4 :         Promise.allSettled(promises).then(() => this.setState({}));
     510            4 :     }
     511              : 
     512              :     /**
     513              :       * Sort units by alphabetically - failed units go on the top of the list
     514              :       */
     515           26 :     compareUnits(unit_a_t, unit_b_t) {
     516           26 :         const unit_a = unit_a_t[1];
     517           26 :         const unit_b = unit_b_t[1];
     518            8 :         const failed_a = unit_a.HasFailed ? 1 : 0;
     519            8 :         const failed_b = unit_b.HasFailed ? 1 : 0;
     520            4 :         const pinned_a = this.state.pinnedUnits.includes(unit_a.Id) ? 1 : 0;
     521            3 :         const pinned_b = this.state.pinnedUnits.includes(unit_b.Id) ? 1 : 0;
     522              : 
     523           26 :         if (!unit_a || !unit_b)
     524            2 :             return 0;
     525              : 
     526           26 :         if (failed_a != failed_b)
     527            8 :             return failed_b - failed_a;
     528           26 :         else if (pinned_a != pinned_b)
     529            4 :             return pinned_b - pinned_a;
     530              :         else
     531           26 :             return unit_a_t[0].localeCompare(unit_b_t[0]);
     532           26 :     }
     533              : 
     534            4 :     addTimerProperties(timer_props, unit) {
     535            4 :         const last_trigger_usec = timer_props.LastTriggerUSec.v;
     536              :         // systemd puts -1 into an unsigned int type for the various *USec* properties
     537              :         // JS rounds these to a float which is > MAX_UINT64, but the comparison works
     538            3 :         if (last_trigger_usec > 0 && last_trigger_usec < MAX_UINT64)
     539            3 :             unit.LastTriggerTime = timeformat.dateTime(last_trigger_usec / 1000);
     540              :         else
     541            4 :             unit.LastTriggerTime = _("unknown");
     542              : 
     543            4 :         const next_realtime = timer_props.NextElapseUSecRealtime?.v;
     544            4 :         const next_monotonic = timer_props.NextElapseUSecMonotonic?.v;
     545            4 :         let next_run_time = null;
     546            4 :         if (next_realtime > 0 && next_realtime < MAX_UINT64)
     547            4 :             next_run_time = next_realtime;
     548            4 :         else if (next_monotonic > 0 && next_monotonic < MAX_UINT64 && monotonic_timer_base !== null)
     549            4 :             next_run_time = next_monotonic + monotonic_timer_base;
     550              : 
     551            4 :         unit.NextRunTime = next_run_time ? timeformat.dateTime(next_run_time / 1000) : _("unknown");
     552            4 :     }
     553              : 
     554            4 :     addTimerPropertiesFull(timer_props, unit) {
     555            4 :         this.addTimerProperties(timer_props, unit);
     556              : 
     557            4 :         unit.TimersCalendar = timer_props.TimersCalendar.v;
     558            4 :         unit.TimersMonotonic = timer_props.TimersMonotonic.v;
     559            4 :     }
     560              : 
     561              :     /* Add some computed properties into a unit object - does not call setState */
     562           26 :     updateComputedProperties(unit) {
     563           26 :         unit.HasFailed = unit.ActiveState == "failed" || (
     564            2 :             unit.LoadState && unit.LoadState !== "loaded" && unit.LoadState !== "masked");
     565              : 
     566           26 :         unit.CombinedState = this.activeState[unit.ActiveState] || unit.ActiveState;
     567            3 :         if (unit.LoadState && unit.LoadState !== "loaded" && unit.LoadState !== "masked")
     568            2 :             unit.CombinedState = cockpit.format("$0 ($1)", unit.CombinedState, this.loadState[unit.LoadState]);
     569              : 
     570           18 :         unit.AutomaticStartup = this.unitFileState[unit.UnitFileState] || unit.UnitFileState;
     571              : 
     572           26 :         unit.IsPinned = this.state.pinnedUnits.includes(unit.Id);
     573           26 :     }
     574              : 
     575           30 :     processFailedUnits() {
     576           30 :         const failed = new Set();
     577           30 :         const tabErrors = { };
     578              : 
     579           30 :         Object.values(this.units).forEach(u => {
     580           11 :             if (u.ActiveState == "failed" && u.LoadState != "not-found") {
     581           11 :                 const suffix = u.Id.substring(u.Id.lastIndexOf('.') + 1);
     582           11 :                 if (service_tabs_suffixes.includes(suffix)) {
     583           11 :                     tabErrors[suffix] = true;
     584           11 :                     failed.add(u.Id);
     585           11 :                 }
     586           11 :             }
     587           30 :         });
     588           30 :         this.props.setTabErrors(tabErrors);
     589              : 
     590           11 :         if (failed.size > 0) {
     591           11 :             page_status.set_own({
     592           11 :                 type: "error",
     593           11 :                 title: cockpit.format(cockpit.ngettext("$0 service has failed",
     594           11 :                                                        "$0 services have failed",
     595           11 :                                                        failed.size), failed.size),
     596           11 :                 details: [...failed]
     597           11 :             });
     598           10 :         } else {
     599           29 :             page_status.set_own(null);
     600           29 :         }
     601           30 :     }
     602              : 
     603              :     // compute filtered and sorted list of [Id, unit]
     604           26 :     computeSelectedUnits() {
     605           26 :         const unitType = '.' + this.props.activeTab;
     606           26 :         const options = cockpit.location.options;
     607           25 :         const currentTextFilter = decodeURIComponent(options.name || '').toLowerCase();
     608           26 :         const filters = {
     609           26 :             activeState: JSON.parse(options.activestate || '[]'),
     610           26 :             fileState: JSON.parse(options.filestate || '[]')
     611           26 :         };
     612           26 :         const selectedUnits = [];
     613           26 :         const ids = new Set();
     614              : 
     615           26 :         [...Object.entries(this.units), ...Object.entries(this.unit_files)].forEach(([idx, unit]) => {
     616           26 :             if (!unit.Id?.endsWith(unitType))
     617           26 :                 return;
     618              : 
     619           26 :             if (unit.LoadState === "not-found")
     620           26 :                 return;
     621              : 
     622              :             // avoid showing unloaded units when there is a loaded one
     623           26 :             if (ids.has(unit.Id))
     624           26 :                 return;
     625           26 :             ids.add(unit.Id);
     626              : 
     627           26 :             const UnitFileState = unit.UnitFileState ?? this.unit_files[unit.Id]?.UnitFileState;
     628              : 
     629            4 :             if (currentTextFilter && !((unit.Description && unit.Description.toLowerCase().includes(currentTextFilter)) ||
     630            4 :                 unit.Id.toLowerCase().includes(currentTextFilter)))
     631           26 :                 return;
     632              : 
     633            3 :             if (filters.fileState.length && this.unitFileState[UnitFileState] &&
     634            3 :                 !filters.fileState.includes(this.unitFileState[UnitFileState]))
     635           26 :                 return;
     636              : 
     637            3 :             if (filters.activeState.length && this.activeState[unit.ActiveState] &&
     638            3 :                 !filters.activeState.includes(this.activeState[unit.ActiveState]))
     639           26 :                 return;
     640              : 
     641            6 :             const augmentedUnit = { ...unit, UnitFileState, ...this.timers?.[idx] };
     642           26 :             this.updateComputedProperties(augmentedUnit);
     643           26 :             selectedUnits.push([unit.Id, augmentedUnit]);
     644           26 :         });
     645              : 
     646           26 :         selectedUnits.sort(this.compareUnits);
     647              : 
     648              :         // lazy-load timers
     649            6 :         if (this.props.activeTab === 'timer' && this.timers === null)
     650            6 :             this.loadTimers();
     651              : 
     652           26 :         return selectedUnits;
     653           26 :     }
     654              : 
     655           33 :     render() {
     656           33 :         if (this.state.error)
     657            3 :             return <EmptyStatePanel title={_("Loading of units failed")} icon={ExclamationCircleIcon} paragraph={this.state.error} />;
     658              : 
     659              :         /* Navigation: unit details page with a path, service list without;
     660              :          * the details page does its own loading, we don't need to wait for isFullyLoaded */
     661           33 :         const path = cockpit.location.path;
     662           26 :         if (path.length == 1) {
     663           26 :             const unit_id = path[0];
     664              : 
     665           23 :             return <Service unitIsValid={unitId => this.unit_files[unitId] || this.knownIds.has(unitId) }
     666           26 :                             owner={this.props.owner}
     667           26 :                             key={unit_id}
     668           26 :                             unitId={unit_id}
     669           26 :                             dbusClient={systemd_client[this.props.owner]}
     670           26 :                             addTimerProperties={this.addTimerPropertiesFull}
     671           26 :                             pinnedUnits={this.state.pinnedUnits}
     672           26 :             />;
     673           26 :         }
     674              : 
     675           29 :         if (!this.state.isFullyLoaded)
     676           29 :             return <EmptyStatePanel loading title={_("Loading...")} paragraph={_("Listing units")} />;
     677              : 
     678           27 :         const fileStateDropdownOptions = [
     679           27 :             { value: 'enabled', label: _("Enabled") },
     680           27 :             { value: 'disabled', label: _("Disabled") },
     681           27 :             { value: 'static', label: _("Static") },
     682           27 :         ];
     683           26 :         this.seenUnitFileStates.forEach(unitFileState => {
     684           26 :             if (!['enabled', 'disabled', 'static'].includes(unitFileState.split('-runtime')[0])) {
     685           26 :                 fileStateDropdownOptions.push({ value: unitFileState, label: this.unitFileState[unitFileState] });
     686           26 :             }
     687           26 :         });
     688           27 :         const activeStateDropdownOptions = [
     689           27 :             { value: 'active', label: _("Running") },
     690           27 :             { value: 'inactive', label: _("Not running") },
     691           27 :         ];
     692           26 :         this.seenActiveStates.forEach(activeState => {
     693            6 :             if (!['active', 'activating', 'inactive', 'deactivating'].includes(activeState)) {
     694            6 :                 activeStateDropdownOptions.push({ value: activeState, label: this.activeState[activeState] });
     695            6 :             }
     696           26 :         });
     697           27 :         const activeTab = this.props.activeTab;
     698              : 
     699            1 :         const onClearAllFilters = () => {
     700            1 :             this.onOptionsChanged({
     701            1 :                 name: '',
     702            1 :                 activestate: '[]',
     703            1 :                 filestate: '[]',
     704            1 :             });
     705            1 :         };
     706              : 
     707           27 :         return (
     708           27 :             <PageSection hasBodyWrapper={false}>
     709           27 :                 <Card isPlain isCompact>
     710           27 :                     <CardHeader id='services-card-header'>
     711           27 :                         <ServicesPageFilters activeStateDropdownOptions={activeStateDropdownOptions}
     712           27 :                                             fileStateDropdownOptions={fileStateDropdownOptions}
     713           27 :                                             onClearAllFilters={onClearAllFilters}
     714           27 :                                             loadingUnits={this.props.isLoading}
     715           27 :                                             options={cockpit.location.options}
     716           27 :                                             onOptionsChanged={this.onOptionsChanged}
     717           27 :                         />
     718           27 :                     </CardHeader>
     719           27 :                     <ServicesList key={cockpit.format("$0-list", activeTab)}
     720           27 :                                   isTimer={activeTab == 'timer'}
     721           27 :                                   onClearAllFilters={onClearAllFilters}
     722           27 :                                   units={this.computeSelectedUnits()} />
     723           27 :                 </Card>
     724           27 :             </PageSection>
     725              :         );
     726           33 :     }
     727           33 : }
     728              : 
     729           26 : const ServicesPageFilters = ({
     730           26 :     activeStateDropdownOptions,
     731           26 :     fileStateDropdownOptions,
     732           26 :     loadingUnits,
     733           26 :     options,
     734           26 :     onOptionsChanged,
     735           26 :     onClearAllFilters,
     736           26 : }) => {
     737           26 :     const { activestate, filestate, name } = options;
     738              : 
     739           25 :     const currentTextFilter = decodeURIComponent(name || "");
     740            1 :     const setCurrentTextFilter = val => {
     741            1 :         onOptionsChanged({ name: encodeURIComponent(val) });
     742            1 :     };
     743              : 
     744           26 :     const filters = {
     745           26 :         activeState: JSON.parse(activestate || '[]'),
     746           26 :         fileState: JSON.parse(filestate || '[]'),
     747           26 :     };
     748              : 
     749            1 :     const setFilters = val => {
     750            1 :         onOptionsChanged({
     751            1 :             activestate: JSON.stringify(val.activeState),
     752            1 :             filestate: JSON.stringify(val.fileState),
     753            1 :         });
     754            1 :     };
     755              : 
     756            1 :     const onSelect = (type, checked, selection) => {
     757            0 :         setFilters({ ...filters, [type]: checked ? [...filters[type], selection] : filters[type].filter(value => value !== selection) });
     758            1 :     };
     759              : 
     760            1 :     const onActiveStateSelect = (selection, checked) => {
     761            1 :         onSelect('activeState', checked, selection);
     762            1 :     };
     763              : 
     764            1 :     const onFileStateSelect = (selection, checked) => {
     765            1 :         onSelect('fileState', checked, selection);
     766            1 :     };
     767              : 
     768            1 :     const getFilterLabelKey = (typeLabel) => {
     769            1 :         if (typeLabel == 'Active state')
     770            0 :             return 'activeState';
     771            0 :         else if (typeLabel == 'File state')
     772            0 :             return 'fileState';
     773            1 :     };
     774              : 
     775            1 :     const onDeleteChip = (typeLabel = '', id = '') => {
     776            1 :         const type = getFilterLabelKey(typeLabel);
     777              : 
     778            1 :         if (type) {
     779            1 :             setFilters({ ...filters, [type]: filters[type].filter(s => s !== id) });
     780            0 :         } else {
     781            0 :             setFilters({
     782            0 :                 activeState: [],
     783            0 :                 fileState: []
     784            0 :             });
     785            0 :         }
     786            1 :     };
     787              : 
     788            0 :     const onDeleteChipGroup = (typeLabel) => {
     789            0 :         const type = getFilterLabelKey(typeLabel);
     790              : 
     791            0 :         if (type)
     792            0 :             setFilters({ ...filters, [type]: [] });
     793              :         else
     794            0 :             setFilters({
     795            0 :                 activeState: [],
     796            0 :                 fileState: []
     797            0 :             });
     798            0 :     };
     799              : 
     800            1 :     const onTextFilterChanged = textFilter => {
     801            1 :         setCurrentTextFilter(textFilter);
     802            1 :     };
     803              : 
     804           26 :     const toolbarItems =
     805           26 :         <ToolbarToggleGroup toggleIcon={<><span className="pf-v6-c-button__icon pf-m-start"><FilterIcon /></span>{_("Toggle filters")}</>} breakpoint="sm"
     806           26 :                             variant="filter-group">
     807           26 :             <ToolbarItem>
     808           26 :                 <SearchInput id="services-text-filter"
     809           26 :                              className="services-text-filter"
     810           26 :                              placeholder={_("Filter by name or description")}
     811           26 :                              value={currentTextFilter}
     812            1 :                              onChange={(_, val) => onTextFilterChanged(val)}
     813            0 :                              onClear={() => onTextFilterChanged('')} />
     814           26 :             </ToolbarItem>
     815           26 :             <ToolbarFilter labels={filters.activeState}
     816           26 :                            deleteLabel={onDeleteChip}
     817           26 :                            deleteLabelGroup={onDeleteChipGroup}
     818           26 :                            categoryName={_("Active state")}>
     819           26 :                 <CheckboxSelect
     820           26 :                     toggleProps={{
     821           26 :                         id: "services-dropdown-active-state",
     822           26 :                         "aria-label": _("Active state")
     823           26 :                     }}
     824           26 :                     toggleContent={_("Active state")}
     825           26 :                     onSelect={onActiveStateSelect}
     826           26 :                     selected={filters.activeState}
     827           26 :                     options={activeStateDropdownOptions.map(option => {
     828           26 :                         return {
     829           26 :                             value: option.label, // sic
     830           26 :                             content: option.label,
     831           26 :                             "data-label": option.label,
     832           26 :                         };
     833           26 :                     })} />
     834           26 :             </ToolbarFilter>
     835           26 :             <ToolbarFilter labels={filters.fileState}
     836           26 :                            deleteLabel={onDeleteChip}
     837           26 :                            deleteLabelGroup={onDeleteChipGroup}
     838           26 :                            categoryName={_("File state")}>
     839           26 :                 <CheckboxSelect
     840           26 :                     toggleProps={{
     841           26 :                         id: "services-dropdown-file-state",
     842           26 :                         "aria-label": _("File state")
     843           26 :                     }}
     844           26 :                     toggleContent={_("File state")}
     845           26 :                     onSelect={onFileStateSelect}
     846           26 :                     selected={filters.fileState}
     847           26 :                     options={fileStateDropdownOptions.map(option => {
     848           26 :                         return {
     849           26 :                             value: option.label, // sic
     850           26 :                             content: option.label,
     851           26 :                             "data-label": option.label,
     852           26 :                         };
     853           26 :                     })} />
     854           26 :             </ToolbarFilter>
     855           26 :         </ToolbarToggleGroup>;
     856              : 
     857           26 :     return (
     858           26 :         <Toolbar
     859           26 :                 data-loading={loadingUnits}
     860           26 :                  clearAllFilters={onClearAllFilters}
     861           26 :                  className="pf-m-sticky-top ct-compact services-toolbar"
     862           26 :                  id="services-toolbar"
     863            0 :                  numberOfFiltersText={n => cockpit.format(_("$0 filters applied"), n)}>
     864           26 :             <ToolbarContent>{toolbarItems}</ToolbarContent>
     865           26 :         </Toolbar>
     866              :     );
     867           26 : };
     868              : 
     869           33 : const ServicesPage = () => {
     870           33 :     const [tabErrors, setTabErrors] = useState({});
     871           33 :     const [loggedUser, setLoggedUser] = useState();
     872           33 :     const [isLoading, setIsLoading] = useState(false);
     873              : 
     874           33 :     useEffect(() => {
     875           33 :         cockpit.user()
     876           33 :                 .then(user => setLoggedUser(user.name))
     877            0 :                 .catch(ex => console.warn(ex.message));
     878           33 :     }, []);
     879              : 
     880              :     /* Listen for permission changes for "Create timer" button */
     881           33 :     useEvent(superuser, "changed");
     882              :     // trigger re-renders when location changes (e.g. through changing filters)
     883           33 :     usePageLocation();
     884              : 
     885           33 :     const options = cockpit.location.options;
     886           33 :     const activeTab = options.type || 'service';
     887           26 :     const owner = options.owner || 'system';
     888            0 :     const setOwner = (owner) => cockpit.location.go(cockpit.location.path, { ...cockpit.location.options, owner });
     889              : 
     890            3 :     if (owner !== 'system' && owner !== 'user') {
     891            3 :         console.warn("not a valid location: " + JSON.stringify(cockpit.location));
     892            3 :         cockpit.location = '';
     893            3 :         return;
     894            3 :     }
     895              : 
     896           33 :     return (
     897           33 :         <WithDialogs>
     898           33 :             <Page className='pf-m-no-sidebar'>
     899           33 :                 {cockpit.location.path.length == 0 &&
     900           29 :                 <PageSection hasBodyWrapper={false} className="services-header">
     901           29 :                     <Flex>
     902           29 :                         <ServiceTabs activeTab={activeTab}
     903           29 :                                       tabErrors={tabErrors}
     904            7 :                                       onChange={activeTab => {
     905            7 :                                           cockpit.location.go(cockpit.location.path, { ...cockpit.location.options, type: activeTab });
     906            7 :                                       }} />
     907           29 :                         <FlexItem align={{ default: 'alignRight' }}>
     908           29 :                             {loggedUser && loggedUser !== 'root' && <ToggleGroup>
     909           29 :                                 <ToggleGroupItem isSelected={owner == "system"}
     910           29 :                                                                                           buttonId="system"
     911           29 :                                                                                           text={_("System")}
     912            0 :                                                                                           onChange={() => setOwner("system")} />
     913           29 :                                 <ToggleGroupItem isSelected={owner == "user"}
     914           29 :                                                                                           buttonId="user"
     915           29 :                                                                                           text={_("User")}
     916            0 :                                                                                           onChange={() => setOwner("user")} />
     917           29 :                             </ToggleGroup>}
     918           29 :                         </FlexItem>
     919            6 :                         {activeTab == "timer" && owner == "system" && superuser.allowed && <CreateTimerDialogButton isLoading={isLoading} owner={owner} />}
     920           29 :                     </Flex>
     921           29 :                 </PageSection>}
     922           33 :                 <ServicesPageBody
     923           33 :                     key={owner}
     924           33 :                     activeTab={activeTab}
     925           33 :                     owner={owner}
     926           33 :                     privileged={superuser.allowed}
     927           33 :                     setTabErrors={setTabErrors}
     928           33 :                     isLoading={isLoading}
     929           33 :                     setIsLoading={setIsLoading}
     930           33 :                 />
     931           33 :             </Page>
     932           33 :         </WithDialogs>
     933              :     );
     934           33 : };
     935              : 
     936           33 : function init() {
     937           33 :     const root = createRoot(document.getElementById('services'));
     938           33 :     root.render(<ServicesPage />);
     939           33 : }
     940              : 
     941           33 : document.addEventListener("DOMContentLoaded", init);
        

Generated by: LCOV version 2.0-1