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 29 : }, async (path, _iface, _signal, [iface, props]) => {
304 29 : if (this.props.isLoading || this.reloading)
305 29 : return;
306 :
307 : // ignore uninteresting unit types
308 29 : if (!this.isUnitHandled(path))
309 29 : return;
310 :
311 : // ignore when timers did not yet get shown
312 5 : if (iface === s_bus.I_TIMER && this.timers !== null) {
313 5 : if (!this.timers[path])
314 5 : this.timers[path] = {};
315 5 : this.addTimerProperties(props, this.timers[path]);
316 5 : debug("timer PropertiesChanged on", path, JSON.stringify(this.timers[path]));
317 5 : return;
318 5 : }
319 :
320 : // ignore uninteresting interfaces
321 29 : if (iface !== s_bus.I_UNIT)
322 29 : return;
323 :
324 29 : let unit = this.units[path];
325 :
326 24 : if (!unit) {
327 : // this happens when starting an unloaded unit; unfortunately Units props is very incomplete, so we need a GetAll
328 24 : debug("unit PropertiesChanged on previously unloaded unit", path);
329 24 : try {
330 23 : [props] = await systemd_client[this.props.owner].call(path, s_bus.I_PROPS, "GetAll", [s_bus.I_UNIT]);
331 1 : } catch (ex) { // not-covered: OS error
332 1 : console.warn("GetAll Unit for unknown unit", path, "failed:", ex.toString()); // not-covered: OS error
333 1 : return; // not-covered: OS error
334 1 : }
335 23 : unit = {};
336 23 : this.units[path] = unit;
337 23 : }
338 :
339 : // unwrap variants
340 29 : for (const prop of ["ActiveState", "LoadState", "Description", "Id", "UnitFileState"]) {
341 29 : if (props[prop])
342 29 : unit[prop] = props[prop].v;
343 29 : }
344 29 : this.knownIds.add(unit.Id);
345 29 : debug("unit PropertiesChanged on", path, "complete:", JSON.stringify(unit));
346 :
347 29 : this.processFailedUnits();
348 29 : this.setState({ });
349 29 : });
350 :
351 : // handle transient units
352 30 : 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 30 : if (this.reloading)
355 30 : return;
356 :
357 4 : if (this.units[objpath]?.UnitFileState === 'transient') {
358 4 : debug("UnitRemoved of transient", objpath);
359 4 : this.knownIds.delete(this.units[objpath]?.Id);
360 4 : delete this.units[objpath];
361 4 : this.processFailedUnits();
362 4 : this.setState({ });
363 4 : }
364 30 : });
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 9 : 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 1 : failed.forEach(([
414 1 : Id, Description, LoadState, ActiveState, _substate, _followUnit, ObjectPath,
415 1 : _is_job_queued, _job_type, _job_path
416 1 : ]) => {
417 1 : if (!this.isUnitHandled(Id))
418 1 : return;
419 :
420 1 : units[ObjectPath] = { Id, Description, LoadState, ActiveState };
421 1 : });
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 7 : 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 4 : 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 31 : processFailedUnits() {
576 31 : const failed = new Set();
577 31 : const tabErrors = { };
578 :
579 31 : Object.values(this.units).forEach(u => {
580 12 : if (u.ActiveState == "failed" && u.LoadState != "not-found") {
581 12 : const suffix = u.Id.substring(u.Id.lastIndexOf('.') + 1);
582 12 : if (service_tabs_suffixes.includes(suffix)) {
583 12 : tabErrors[suffix] = true;
584 12 : failed.add(u.Id);
585 12 : }
586 12 : }
587 31 : });
588 31 : this.props.setTabErrors(tabErrors);
589 :
590 12 : if (failed.size > 0) {
591 12 : page_status.set_own({
592 12 : type: "error",
593 12 : title: cockpit.format(cockpit.ngettext("$0 service has failed",
594 12 : "$0 services have failed",
595 12 : failed.size), failed.size),
596 12 : details: [...failed]
597 12 : });
598 10 : } else {
599 29 : page_status.set_own(null);
600 29 : }
601 31 : }
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);
|