* Copyright (C) 2020 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import '../lib/patternfly/patternfly-6-cockpit.scss';
import 'polyfills';
import 'cockpit-dark-theme';
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;
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;
let monotonic_timer_base = null;
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() {
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 => {
* 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;
}
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());
}
});
});
}
*
* - 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 = {
isFullyLoaded: false,
error: null,
pinnedUnits: [],
};
*
* 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
*/
Id,
Description, LoadState, ActiveState,
UnitFileState, // if unit has a file and got a PropertiesChanged
} */
this.units = {};
this.knownIds = new Set();
this.timers = null;
name/id (e.g. foo.service) → { Id, UnitFileState, ActiveState ("inactive" or empty for aliases) } */
this.unit_files = {};
this.seenActiveStates = new Set();
this.seenUnitFileStates = new Set();
this.reloading = false;
this.loadState = {
stub: _("Stub"),
loaded: "",
"not-found": _("Not found"),
"bad-setting": _("Bad setting"),
error: _("Error"),
merged: _("Merged"),
masked: "",
};
this.activeState = {
active: _("Running"),
reloading: _("Reloading"),
inactive: _("Not running"),
failed: _("Failed to start"),
activating: _("Running"),
deactivating: _("Not running"),
maintenance: _("Maintenance"),
};
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);
* 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");
}
});
* - 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;
if (!this.isUnitHandled(path))
return;
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;
}
if (iface !== s_bus.I_UNIT)
return;
let unit = this.units[path];
if (!unit) {
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) {
console.warn("GetAll Unit for unknown unit", path, "failed:", ex.toString());
return;
}
unit = {};
this.units[path] = unit;
}
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({ });
});
systemd_client[this.props.owner].subscribe({ interface: s_bus.I_MANAGER, member: "UnitRemoved" }, (_path, _iface, _signal, [_id, objpath]) => {
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));
}
* 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()));
}
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();
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();
unitsResults.forEach(([
Id, Description, LoadState, ActiveState, _substate, _followUnit, ObjectPath,
_is_job_queued, _job_type, _job_path
]) => {
if (!this.isUnitHandled(Id))
return;
if (LoadState !== 'not-found')
this.seenActiveStates.add(ActiveState);
units[ObjectPath] = { Id, Description, LoadState, ActiveState };
this.knownIds.add(Id);
});
const unit_files = {};
unitFilesResults.forEach(([UnitFilePath, UnitFileState]) => {
const Id = UnitFilePath.split('/').pop();
if (!this.isUnitHandled(Id) || this.isTemplate(Id))
return;
this.seenUnitFileStates.add(UnitFileState);
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()) }))
.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())));
}
});
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;
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;
}
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);
}
}
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;
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);
if (this.props.activeTab === 'timer' && this.timers === null)
this.loadTimers();
return selectedUnits;
}
render() {
if (this.state.error)
return <EmptyStatePanel title={_("Loading of units failed")} icon={ExclamationCircleIcon} paragraph={this.state.error} />;
* 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 <Service unitIsValid={unitId => 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 <EmptyStatePanel loading title={_("Loading...")} paragraph={_("Listing units")} />;
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 (
<PageSection hasBodyWrapper={false}>
<Card isPlain isCompact>
<CardHeader id='services-card-header'>
<ServicesPageFilters activeStateDropdownOptions={activeStateDropdownOptions}
fileStateDropdownOptions={fileStateDropdownOptions}
onClearAllFilters={onClearAllFilters}
loadingUnits={this.props.isLoading}
options={cockpit.location.options}
onOptionsChanged={this.onOptionsChanged}
/>
</CardHeader>
<ServicesList key={cockpit.format("$0-list", activeTab)}
isTimer={activeTab === 'timer'}
onClearAllFilters={onClearAllFilters}
units={this.computeSelectedUnits()} />
</Card>
</PageSection>
);
}
}
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 =
<ToolbarToggleGroup toggleIcon={<><span className="pf-v6-c-button__icon pf-m-start"><FilterIcon /></span>{_("Toggle filters")}</>} breakpoint="sm"
variant="filter-group">
<ToolbarItem>
<SearchInput id="services-text-filter"
className="services-text-filter"
placeholder={_("Filter by name or description")}
value={currentTextFilter}
onChange={(_, val) => onTextFilterChanged(val)}
onClear={() => onTextFilterChanged('')} />
</ToolbarItem>
<ToolbarFilter labels={filters.activeState}
deleteLabel={onDeleteChip}
deleteLabelGroup={onDeleteChipGroup}
categoryName={_("Active state")}>
<CheckboxSelect
toggleProps={{
id: "services-dropdown-active-state",
"aria-label": _("Active state")
}}
toggleContent={_("Active state")}
onSelect={onActiveStateSelect}
selected={filters.activeState}
options={activeStateDropdownOptions.map(option => {
return {
value: option.label,
content: option.label,
"data-label": option.label,
};
})} />
</ToolbarFilter>
<ToolbarFilter labels={filters.fileState}
deleteLabel={onDeleteChip}
deleteLabelGroup={onDeleteChipGroup}
categoryName={_("File state")}>
<CheckboxSelect
toggleProps={{
id: "services-dropdown-file-state",
"aria-label": _("File state")
}}
toggleContent={_("File state")}
onSelect={onFileStateSelect}
selected={filters.fileState}
options={fileStateDropdownOptions.map(option => {
return {
value: option.label,
content: option.label,
"data-label": option.label,
};
})} />
</ToolbarFilter>
</ToolbarToggleGroup>;
return (
<Toolbar
data-loading={loadingUnits}
clearAllFilters={onClearAllFilters}
className="pf-m-sticky-top ct-compact services-toolbar"
id="services-toolbar"
numberOfFiltersText={n => cockpit.format(_("$0 filters applied"), n)}>
<ToolbarContent>{toolbarItems}</ToolbarContent>
</Toolbar>
);
};
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));
}, []);
useEvent(superuser, "changed");
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 (
<WithDialogs>
<Page className='pf-m-no-sidebar'>
{cockpit.location.path.length === 0 &&
<PageSection hasBodyWrapper={false} className="services-header">
<Flex>
<ServiceTabs activeTab={activeTab}
tabErrors={tabErrors}
onChange={activeTab => {
cockpit.location.go(cockpit.location.path, { ...cockpit.location.options, type: activeTab });
}} />
<FlexItem align={{ default: 'alignRight' }}>
{loggedUser && loggedUser !== 'root' && <ToggleGroup>
<ToggleGroupItem isSelected={owner === "system"}
buttonId="system"
text={_("System")}
onChange={() => setOwner("system")} />
<ToggleGroupItem isSelected={owner === "user"}
buttonId="user"
text={_("User")}
onChange={() => setOwner("user")} />
</ToggleGroup>}
</FlexItem>
{activeTab === "timer" && owner === "system" && superuser.allowed && <CreateTimerDialogButton isLoading={isLoading} owner={owner} />}
</Flex>
</PageSection>}
<ServicesPageBody
key={owner}
activeTab={activeTab}
owner={owner}
privileged={superuser.allowed}
setTabErrors={setTabErrors}
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
</Page>
</WithDialogs>
);
};
function init() {
const root = createRoot(document.getElementById('services'));
root.render(<ServicesPage />);
}
document.addEventListener("DOMContentLoaded", init);