/* * Copyright (C) 2020 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import '../lib/patternfly/patternfly-6-cockpit.scss'; import 'polyfills'; // once per application import 'cockpit-dark-theme'; // once per page import React, { useState, useEffect } from "react"; import { createRoot } from 'react-dom/client'; import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; import { Page, PageSection, } from "@patternfly/react-core/dist/esm/components/Page/index.js"; import { Card, CardHeader } from "@patternfly/react-core/dist/esm/components/Card/index.js"; import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchInput/index.js"; import { ToggleGroup, ToggleGroupItem } from "@patternfly/react-core/dist/esm/components/ToggleGroup/index.js"; import { Toolbar, ToolbarContent, ToolbarFilter, ToolbarItem, ToolbarToggleGroup } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js"; import { ExclamationCircleIcon, FilterIcon } from '@patternfly/react-icons'; import { CheckboxSelect } from "cockpit-components-checkbox-select"; import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; import { Service } from "./service.jsx"; import { ServiceTabs, service_tabs_suffixes } from "./service-tabs"; import { ServicesList } from "./services-list.jsx"; import { CreateTimerDialogButton } from "./timer-dialog.jsx"; import { page_status } from "notifications"; import * as python from "python"; import * as timeformat from "timeformat"; import cockpit from "cockpit"; import { superuser } from 'superuser'; import { useEvent, usePageLocation } from "hooks"; import { WithDialogs } from "dialogs.jsx"; import s_bus from "./busnames.js"; import "./services.scss"; const _ = cockpit.gettext; // As long as we have long-running superuser channels, we need to // reload the page when the access level changes. // superuser.reload_page_on_change(); export const systemd_client = { system: cockpit.dbus(s_bus.BUS_NAME, { bus: "system", superuser: "try" }), user: cockpit.dbus(s_bus.BUS_NAME, { bus: "session" }), }; const timedate_client = cockpit.dbus('org.freedesktop.timedate1'); export let clock_realtime_now = 0; // ms since epoch; call updateTime() before using let monotonic_timer_base = null; // µs export const MAX_UINT64 = 2 ** 64 - 1; function debug() { if (window.debugging === "all" || window.debugging?.includes("services")) console.debug.apply(console, arguments); } export function updateTime() { // To correctly interpret monotonic timers, we need to read CLOCK_MONOTONIC. This cannot be read with shell tools python.spawn("import time; print(int(time.clock_gettime(time.CLOCK_REALTIME) * 1000000), int(time.clock_gettime(time.CLOCK_MONOTONIC) * 1000000))", null, { err: "message" }) .then(output => { const [realtime_us, monotonic_us] = output.split(' ').map(t => parseInt(t)); clock_realtime_now = realtime_us / 1000; monotonic_timer_base = realtime_us - monotonic_us; debug("Read clocks with Python; realtime", clock_realtime_now, "monotonic base", monotonic_timer_base); }) .catch(ex => { /* If Python is not available, fall back to reading CLOCK_BOOTTIME from /proc/timer-list. * This is readable by root only */ console.log("Failed to read clocks with Python, using fallback:", ex.toString()); Promise.allSettled([ cockpit.spawn(["date", "+%s"]), cockpit.file("/proc/timer_list", { superuser: "try" }).read(), ]) .then(([date_result, timer_list_result]) => { if (date_result.status === "fulfilled") { clock_realtime_now = parseInt(date_result.value) * 1000; } else { console.warn("Failed to read realtime clock:", date_result.reason.toString()); return; } // third line is "now at N nsecs" if (timer_list_result.status === "fulfilled") { const match = /now at (\d+) nsecs/.exec(timer_list_result.value); if (match) monotonic_timer_base = clock_realtime_now * 1000 - parseInt(match[1]) / 1000; debug("Read clocks with fallback; realtime", clock_realtime_now, "monotonic base", monotonic_timer_base); } else { console.log("Failed to read /proc/timer_list:", timer_list_result.reason.toString()); } }); }); } /* Notes about the systemd D-Bus API * * - Loading all units, fetching their properties, and listening to JobNew/JobRemoved is * expensive, so the services list does not do that. For 90% of what the list shows we * only need two calls: ListUnits() for enough information about all units which are in * systemd's brain; and ListUnitFiles() to add the inert ones (disabled, stopped, not a * dependency of anything). The only exception are timers, where we have to get the * properties of the Timers interface to show their last and next run. This information * is collected in listUnits(). * * - To keep up with changes, we listen to two signals: PropertiesChanged (which also gets * fired when a unit gets loaded) for run state chanes, and Reloading for file state * changes (like enabling/disabling). * * - When loading an unloaded unit, PropertiesChanged will be fired, but unfortunately in a * rather useless way: it does not contain all properties (thus it needs a GetAll), * and it usually happens for the Timer interface first (when we don't yet have an ID) and * for the Unit interface later; due to that, we track them in two separate dicts. * * - The unit details view does its own independent API communication and state * management. It needs to fetch/interpret a lot of unit properties which are not part * of ListUnits(), but it only needs to do that for a single unit. * * - ListUnitFiles will return unit files that are aliases for other unit files, but * ListUnits will not return aliases. * * - Methods like EnableUnitFiles only change the state of files on disk. A Reload is * necessary to update the state of loaded units. * * - The unit file state as returned by ListUnitFiles is not necessarily the same as the * UnitFileState property of a loaded unit. ListUnitFiles reflects the state of the * files on disk, while a loaded unit is only updated to that state via an explicit * Reload. Thus, be careful to only use the UnitFileState as returned by ListUnitFiles * for unloaded units. Loaded units should use the PropertiesChanged value to reflect * runtime reality. * * A few historical notes which don't apply to the current code, but could be useful in * the future: * * * - A unit that isn't currently loaded has no object path. If you need one, do * LoadUnit(); doing so will emit UnitNew. * * - One can use an object path for a unit that isn't currently loaded. Doing so will load * the unit (and emit UnitNew). * * - JobNew and JobRemoved signals don't include the object path of the affected units, * but we can get those by listening to UnitNew. * * - There might be UnitNew signals for units that are never returned by ListUnits or * ListUnitFiles. These are units that are mentioned in Requires, After, etc or that * people try to load via LoadUnit but that don't actually exist. * * - The "Names" property of a unit only includes those aliases that are currently loaded, * not all. To get all possible aliases, one needs to call ListUnitFiles and match * units via their object path. * * - The unit file state of a alias as returned by ListUnitFiles is always the same as the * unit file state of the primary unit file. * * - A Reload will emit UnitRemoved/UnitNew signals for all units, and no * PropertiesChanges signal for the properties that have changed because of the reload, * such as UnitFileState. */ class ServicesPageBody extends React.Component { constructor(props) { super(props); this.state = { /* State related to the toolbar components */ isFullyLoaded: false, error: null, pinnedUnits: [], }; /* data storage * * do not keep as state, as that requires too much copying, and it's easy to miss updates due to setState() * coalescing; whenever these change, you need to force a state update to re-render */ /* loaded units: ListUnits()/PropertiesChanged for Unit interface; object path → { Id, Description, LoadState, ActiveState, UnitFileState, // if unit has a file and got a PropertiesChanged } */ this.units = {}; // for this.knownIds = new Set(); // active timers: object path → { LastTriggerTime, NextRunTime } (formatted strings) // lazily initialized when actually showing the Timers tab this.timers = null; /* ListUnitFiles() result; updated with daemon reload name/id (e.g. foo.service) → { Id, UnitFileState, ActiveState ("inactive" or empty for aliases) } */ this.unit_files = {}; // other state which should not cause re-renders this.seenActiveStates = new Set(); this.seenUnitFileStates = new Set(); this.reloading = false; // Possible LoadState values: stub, loaded, not-found, bad-setting, error, merged, masked // See: typedef enum UnitLoadStateState https://github.com/systemd/systemd/blob/main/src/basic/unit-def.h this.loadState = { stub: _("Stub"), loaded: "", "not-found": _("Not found"), "bad-setting": _("Bad setting"), error: _("Error"), merged: _("Merged"), masked: "", // We present the masked from the unitFileState }; // Possible ActiveState values: active, reloading, inactive, failed, activating, deactivating, maintenance // See: typedef enum UnitActiveState https://github.com/systemd/systemd/blob/main/src/basic/unit-def.h this.activeState = { active: _("Running"), reloading: _("Reloading"), inactive: _("Not running"), failed: _("Failed to start"), activating: _("Running"), deactivating: _("Not running"), maintenance: _("Maintenance"), }; // Possible UnitFileState values: enabled, enabled-runtime, linked, linked-runtime, alias, masked, masked-runtime, static, disabled, invalid, indirect, generated, transient, bad // See: typedef enum UnitFileState https://github.com/systemd/systemd/blob/main/src/shared/unit-file.h this.unitFileState = { enabled: _("Enabled"), "enabled-runtime": _("Enabled"), disabled: _("Disabled"), linked: _("Linked"), "linked-runtime": _("Linked"), alias: _("Alias"), masked: _("Masked"), "masked-runtime": _("Masked"), static: _("Static"), invalid: _("Invalid"), indirect: _("Indirect"), generated: _("Generated"), transient: _("Transient"), bad: _("Bad"), }; this.listUnits = this.listUnits.bind(this); this.loadPinnedUnits = this.loadPinnedUnits.bind(this); this.onOptionsChanged = this.onOptionsChanged.bind(this); this.compareUnits = this.compareUnits.bind(this); this.addTimerPropertiesFull = this.addTimerPropertiesFull.bind(this); } onOptionsChanged(options) { const currentOptions = { ...cockpit.location.options, ...options }; if (!currentOptions.activestate || options.activestate === "[]") delete currentOptions.activestate; if (!currentOptions.filestate || options.filestate === "[]") delete currentOptions.filestate; if (!currentOptions.name) delete currentOptions.name; cockpit.location.replace(cockpit.location.path, currentOptions); } componentDidMount() { systemd_client[this.props.owner].wait(() => { this.systemd_subscription = systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, "Subscribe", null) .finally(this.listUnits) .catch(error => { if (error.name !== "org.freedesktop.systemd1.AlreadySubscribed" && error.name !== "org.freedesktop.DBus.Error.FileExists") this.setState({ error: cockpit.format(_("Subscribing to systemd signals failed: $0"), error.toString()) }); }); }) .catch(ex => this.setState({ error: cockpit.format(_("Connecting to dbus failed: $0"), ex.toString()) })); cockpit.addEventListener("visibilitychange", () => { if (!cockpit.hidden) { debug("visibilitychange to visible; fully loaded", this.state.isFullyLoaded); /* If the page had only been fetched in the background we need to properly initialize the state now * else just trigger an re-render since we are receiving signals while running in the background and * we update the state but don't re-render */ if (!this.state.isFullyLoaded) this.listUnits(); else this.setState({}); } else { debug("visibilitychange to hidden"); } }); /* Start listening to signals for updates * - when in the middle of reload mute all signals * - We don't need to listen to 'UnitFilesChanged' signal since every time we * perform some file operation we do call Reload which issues 'Reloading' signal */ systemd_client[this.props.owner].subscribe({ interface: s_bus.I_PROPS, member: "PropertiesChanged" }, async (path, _iface, _signal, [iface, props]) => { if (this.props.isLoading || this.reloading) return; // ignore uninteresting unit types if (!this.isUnitHandled(path)) return; // ignore when timers did not yet get shown if (iface === s_bus.I_TIMER && this.timers !== null) { if (!this.timers[path]) this.timers[path] = {}; this.addTimerProperties(props, this.timers[path]); debug("timer PropertiesChanged on", path, JSON.stringify(this.timers[path])); return; } // ignore uninteresting interfaces if (iface !== s_bus.I_UNIT) return; let unit = this.units[path]; if (!unit) { // this happens when starting an unloaded unit; unfortunately Units props is very incomplete, so we need a GetAll debug("unit PropertiesChanged on previously unloaded unit", path); try { [props] = await systemd_client[this.props.owner].call(path, s_bus.I_PROPS, "GetAll", [s_bus.I_UNIT]); } catch (ex) { // not-covered: OS error console.warn("GetAll Unit for unknown unit", path, "failed:", ex.toString()); // not-covered: OS error return; // not-covered: OS error } unit = {}; this.units[path] = unit; } // unwrap variants for (const prop of ["ActiveState", "LoadState", "Description", "Id", "UnitFileState"]) { if (props[prop]) unit[prop] = props[prop].v; } this.knownIds.add(unit.Id); debug("unit PropertiesChanged on", path, "complete:", JSON.stringify(unit)); this.processFailedUnits(); this.setState({ }); }); // handle transient units systemd_client[this.props.owner].subscribe({ interface: s_bus.I_MANAGER, member: "UnitRemoved" }, (_path, _iface, _signal, [_id, objpath]) => { // during daemon reload we get tons of these, ignore if (this.reloading) return; if (this.units[objpath]?.UnitFileState === 'transient') { debug("UnitRemoved of transient", objpath); this.knownIds.delete(this.units[objpath]?.Id); delete this.units[objpath]; this.processFailedUnits(); this.setState({ }); } }); systemd_client[this.props.owner].subscribe({ interface: s_bus.I_MANAGER, member: "Reloading" }, (_path, _iface, _signal, [reloading]) => { this.reloading = reloading; debug("Reloading", reloading); if (!reloading && !this.props.isLoading) this.listUnits(); }); addEventListener('storage', this.loadPinnedUnits); this.loadPinnedUnits(); this.timedated_subscription = timedate_client.subscribe({ path_namespace: "/org/freedesktop/timedate1", interface: s_bus.I_PROPS, member: "PropertiesChanged" }, updateTime); updateTime(); } shouldComponentUpdate(nextProps, nextState) { if (cockpit.hidden) return false; return true; } loadPinnedUnits() { try { this.setState({ pinnedUnits: JSON.parse(localStorage.getItem('systemd:pinnedUnits')) || [] }); } catch (err) { console.warn("exception while parsing systemd:pinnedUnits", err); this.setState({ pinnedUnits: [] }); } } /** * Return if the unit ID or path specified by @name is handled */ isUnitHandled(name) { return service_tabs_suffixes.some(suffix => name.endsWith(suffix)); } /* When the page is running in the background, fetch only information about failed units * in order to update the 'Page Status'. */ listFailedUnits() { return systemd_client[this.props.owner].call(s_bus.O_MANAGER, s_bus.I_MANAGER, "ListUnitsFiltered", [["failed"]]) .then(([failed]) => { const units = {}; failed.forEach(([ Id, Description, LoadState, ActiveState, _substate, _followUnit, ObjectPath, _is_job_queued, _job_type, _job_path ]) => { if (!this.isUnitHandled(Id)) return; units[ObjectPath] = { Id, Description, LoadState, ActiveState }; }); this.units = units; this.processFailedUnits(); }) .catch(ex => console.warn('ListUnitsFiltered failed: ', ex.toString())); // not-covered: OS error } isTemplate(id) { const tp = id.indexOf("@"); const sp = id.lastIndexOf("."); return (tp !== -1 && (tp + 1 === sp || tp + 1 === id.length)); } listUnits() { if (cockpit.hidden) return this.listFailedUnits(); // Reinitialize the state variables for the units this.props.setIsLoading(true); const dbus = systemd_client[this.props.owner]; const units = {}; Promise.all([ dbus.call(s_bus.O_MANAGER, s_bus.I_MANAGER, "ListUnits", null), dbus.call(s_bus.O_MANAGER, s_bus.I_MANAGER, "ListUnitFiles", null) ]) .then(([[unitsResults], [unitFilesResults]]) => { this.knownIds = new Set(); // ListUnits is the primary source of information unitsResults.forEach(([ Id, Description, LoadState, ActiveState, _substate, _followUnit, ObjectPath, _is_job_queued, _job_type, _job_path ]) => { if (!this.isUnitHandled(Id)) return; // We should ignore 'not-found' units when setting the seenActiveStates if (LoadState !== 'not-found') this.seenActiveStates.add(ActiveState); units[ObjectPath] = { Id, Description, LoadState, ActiveState }; this.knownIds.add(Id); }); // unloaded, but available unit files const unit_files = {}; unitFilesResults.forEach(([UnitFilePath, UnitFileState]) => { const Id = UnitFilePath.split('/').pop(); if (!this.isUnitHandled(Id) || this.isTemplate(Id)) return; this.seenUnitFileStates.add(UnitFileState); // there is not enough information to link this to the primary unit which declared the alias // name; that requires a LoadUnit() + Get("Names") + reverse lookup; the details page has the // correct information, so skip the status for aliases // for other units, we default to "inactive"; loaded units will override that const ActiveState = (UnitFileState === "alias") ? undefined : "inactive"; unit_files[Id] = { Id, UnitFileState, ActiveState }; }); this.units = units; this.unit_files = unit_files; this.processFailedUnits(); this.setState({ isFullyLoaded: true }); }) .catch(ex => this.setState({ error: cockpit.format(_("Listing units failed: $0"), ex.toString()) })) // not-covered: OS error .finally(() => this.props.setIsLoading(false)); } loadTimers() { const dbus = systemd_client[this.props.owner]; const promises = []; this.timers = {}; debug("Loading all timers"); Object.entries(this.units).forEach(([path, unit]) => { if (unit.Id.endsWith(".timer")) { promises.push(dbus.call(path, s_bus.I_PROPS, "GetAll", [s_bus.I_TIMER]) .then(([props]) => { this.timers[path] = {}; this.addTimerPropertiesFull(props, this.timers[path]); }) .catch(ex => console.warn("Loading timer information for", path, "failed:", ex.toString()))); // not-covered: OS error } }); Promise.allSettled(promises).then(() => this.setState({})); } /** * Sort units by alphabetically - failed units go on the top of the list */ compareUnits(unit_a_t, unit_b_t) { const unit_a = unit_a_t[1]; const unit_b = unit_b_t[1]; const failed_a = unit_a.HasFailed ? 1 : 0; const failed_b = unit_b.HasFailed ? 1 : 0; const pinned_a = this.state.pinnedUnits.includes(unit_a.Id) ? 1 : 0; const pinned_b = this.state.pinnedUnits.includes(unit_b.Id) ? 1 : 0; if (!unit_a || !unit_b) return 0; if (failed_a !== failed_b) return failed_b - failed_a; else if (pinned_a !== pinned_b) return pinned_b - pinned_a; else return unit_a_t[0].localeCompare(unit_b_t[0]); } addTimerProperties(timer_props, unit) { const last_trigger_usec = timer_props.LastTriggerUSec.v; // systemd puts -1 into an unsigned int type for the various *USec* properties // JS rounds these to a float which is > MAX_UINT64, but the comparison works if (last_trigger_usec > 0 && last_trigger_usec < MAX_UINT64) unit.LastTriggerTime = timeformat.dateTime(last_trigger_usec / 1000); else unit.LastTriggerTime = _("unknown"); const next_realtime = timer_props.NextElapseUSecRealtime?.v; const next_monotonic = timer_props.NextElapseUSecMonotonic?.v; let next_run_time = null; if (next_realtime > 0 && next_realtime < MAX_UINT64) next_run_time = next_realtime; else if (next_monotonic > 0 && next_monotonic < MAX_UINT64 && monotonic_timer_base !== null) next_run_time = next_monotonic + monotonic_timer_base; unit.NextRunTime = next_run_time ? timeformat.dateTime(next_run_time / 1000) : _("unknown"); } addTimerPropertiesFull(timer_props, unit) { this.addTimerProperties(timer_props, unit); unit.TimersCalendar = timer_props.TimersCalendar.v; unit.TimersMonotonic = timer_props.TimersMonotonic.v; } /* Add some computed properties into a unit object - does not call setState */ updateComputedProperties(unit) { unit.HasFailed = unit.ActiveState === "failed" || ( unit.LoadState && unit.LoadState !== "loaded" && unit.LoadState !== "masked"); unit.CombinedState = this.activeState[unit.ActiveState] || unit.ActiveState; if (unit.LoadState && unit.LoadState !== "loaded" && unit.LoadState !== "masked") unit.CombinedState = cockpit.format("$0 ($1)", unit.CombinedState, this.loadState[unit.LoadState]); unit.AutomaticStartup = this.unitFileState[unit.UnitFileState] || unit.UnitFileState; unit.IsPinned = this.state.pinnedUnits.includes(unit.Id); } processFailedUnits() { const failed = new Set(); const tabErrors = { }; Object.values(this.units).forEach(u => { if (u.ActiveState === "failed" && u.LoadState !== "not-found") { const suffix = u.Id.substring(u.Id.lastIndexOf('.') + 1); if (service_tabs_suffixes.includes(suffix)) { tabErrors[suffix] = true; failed.add(u.Id); } } }); this.props.setTabErrors(tabErrors); if (failed.size > 0) { page_status.set_own({ type: "error", title: cockpit.format(cockpit.ngettext("$0 service has failed", "$0 services have failed", failed.size), failed.size), details: [...failed] }); } else { page_status.set_own(null); } } // compute filtered and sorted list of [Id, unit] computeSelectedUnits() { const unitType = '.' + this.props.activeTab; const options = cockpit.location.options; const currentTextFilter = decodeURIComponent(options.name || '').toLowerCase(); const filters = { activeState: JSON.parse(options.activestate || '[]'), fileState: JSON.parse(options.filestate || '[]') }; const selectedUnits = []; const ids = new Set(); [...Object.entries(this.units), ...Object.entries(this.unit_files)].forEach(([idx, unit]) => { if (!unit.Id?.endsWith(unitType)) return; if (unit.LoadState === "not-found") return; // avoid showing unloaded units when there is a loaded one if (ids.has(unit.Id)) return; ids.add(unit.Id); const UnitFileState = unit.UnitFileState ?? this.unit_files[unit.Id]?.UnitFileState; if (currentTextFilter && !((unit.Description && unit.Description.toLowerCase().includes(currentTextFilter)) || unit.Id.toLowerCase().includes(currentTextFilter))) return; if (filters.fileState.length && this.unitFileState[UnitFileState] && !filters.fileState.includes(this.unitFileState[UnitFileState])) return; if (filters.activeState.length && this.activeState[unit.ActiveState] && !filters.activeState.includes(this.activeState[unit.ActiveState])) return; const augmentedUnit = { ...unit, UnitFileState, ...this.timers?.[idx] }; this.updateComputedProperties(augmentedUnit); selectedUnits.push([unit.Id, augmentedUnit]); }); selectedUnits.sort(this.compareUnits); // lazy-load timers if (this.props.activeTab === 'timer' && this.timers === null) this.loadTimers(); return selectedUnits; } render() { if (this.state.error) return ; /* Navigation: unit details page with a path, service list without; * the details page does its own loading, we don't need to wait for isFullyLoaded */ const path = cockpit.location.path; if (path.length === 1) { const unit_id = path[0]; return this.unit_files[unitId] || this.knownIds.has(unitId) } owner={this.props.owner} key={unit_id} unitId={unit_id} dbusClient={systemd_client[this.props.owner]} addTimerProperties={this.addTimerPropertiesFull} pinnedUnits={this.state.pinnedUnits} />; } if (!this.state.isFullyLoaded) return ; const fileStateDropdownOptions = [ { value: 'enabled', label: _("Enabled") }, { value: 'disabled', label: _("Disabled") }, { value: 'static', label: _("Static") }, ]; this.seenUnitFileStates.forEach(unitFileState => { const baseState = unitFileState.split('-runtime')[0]; if (!['enabled', 'disabled', 'static'].includes(baseState)) { const label = this.unitFileState[unitFileState]; if (!fileStateDropdownOptions.some(o => o.label === label)) fileStateDropdownOptions.push({ value: baseState, label }); } }); const activeStateDropdownOptions = [ { value: 'active', label: _("Running") }, { value: 'inactive', label: _("Not running") }, ]; this.seenActiveStates.forEach(activeState => { if (!['active', 'activating', 'inactive', 'deactivating'].includes(activeState)) { activeStateDropdownOptions.push({ value: activeState, label: this.activeState[activeState] }); } }); const activeTab = this.props.activeTab; const onClearAllFilters = () => { this.onOptionsChanged({ name: '', activestate: '[]', filestate: '[]', }); }; return ( ); } } const ServicesPageFilters = ({ activeStateDropdownOptions, fileStateDropdownOptions, loadingUnits, options, onOptionsChanged, onClearAllFilters, }) => { const { activestate, filestate, name } = options; const currentTextFilter = decodeURIComponent(name || ""); const setCurrentTextFilter = val => { onOptionsChanged({ name: encodeURIComponent(val) }); }; const filters = { activeState: JSON.parse(activestate || '[]'), fileState: JSON.parse(filestate || '[]'), }; const setFilters = val => { onOptionsChanged({ activestate: JSON.stringify(val.activeState), filestate: JSON.stringify(val.fileState), }); }; const onSelect = (type, checked, selection) => { setFilters({ ...filters, [type]: checked ? [...filters[type], selection] : filters[type].filter(value => value !== selection) }); }; const onActiveStateSelect = (selection, checked) => { onSelect('activeState', checked, selection); }; const onFileStateSelect = (selection, checked) => { onSelect('fileState', checked, selection); }; const getFilterLabelKey = (typeLabel) => { if (typeLabel === 'Active state') return 'activeState'; else if (typeLabel === 'File state') return 'fileState'; }; const onDeleteChip = (typeLabel = '', id = '') => { const type = getFilterLabelKey(typeLabel); if (type) { setFilters({ ...filters, [type]: filters[type].filter(s => s !== id) }); } else { setFilters({ activeState: [], fileState: [] }); } }; const onDeleteChipGroup = (typeLabel) => { const type = getFilterLabelKey(typeLabel); if (type) setFilters({ ...filters, [type]: [] }); else setFilters({ activeState: [], fileState: [] }); }; const onTextFilterChanged = textFilter => { setCurrentTextFilter(textFilter); }; const toolbarItems = {_("Toggle filters")}} breakpoint="sm" variant="filter-group"> onTextFilterChanged(val)} onClear={() => onTextFilterChanged('')} /> { return { value: option.label, // sic content: option.label, "data-label": option.label, }; })} /> { return { value: option.label, // sic content: option.label, "data-label": option.label, }; })} /> ; return ( cockpit.format(_("$0 filters applied"), n)}> {toolbarItems} ); }; const ServicesPage = () => { const [tabErrors, setTabErrors] = useState({}); const [loggedUser, setLoggedUser] = useState(); const [isLoading, setIsLoading] = useState(false); useEffect(() => { cockpit.user() .then(user => setLoggedUser(user.name)) .catch(ex => console.warn(ex.message)); }, []); /* Listen for permission changes for "Create timer" button */ useEvent(superuser, "changed"); // trigger re-renders when location changes (e.g. through changing filters) usePageLocation(); const options = cockpit.location.options; const activeTab = options.type || 'service'; const owner = options.owner || 'system'; const setOwner = (owner) => cockpit.location.go(cockpit.location.path, { ...cockpit.location.options, owner }); if (owner !== 'system' && owner !== 'user') { console.warn("not a valid location: " + JSON.stringify(cockpit.location)); cockpit.location = ''; return; } return ( {cockpit.location.path.length === 0 && { cockpit.location.go(cockpit.location.path, { ...cockpit.location.options, type: activeTab }); }} /> {loggedUser && loggedUser !== 'root' && setOwner("system")} /> setOwner("user")} /> } {activeTab === "timer" && owner === "system" && superuser.allowed && } } ); }; function init() { const root = createRoot(document.getElementById('services')); root.render(); } document.addEventListener("DOMContentLoaded", init);