;
} else if (this.props.idle_message) {
wait_element =
@@ -167,8 +166,9 @@ class DialogFooter extends React.Component {
key={ caption }
className="apply"
variant={ variant }
+ isLoading={ this.state.action_in_progress && this.state.action_caption_in_progress == caption }
isDanger={ action.danger }
- onClick={ this.action_click.bind(this, action.clicked) }
+ onClick={ this.action_click.bind(this, action.clicked, caption) }
isDisabled={ actions_disabled || action.disabled }
>{ caption }
);
@@ -209,14 +209,24 @@ DialogFooter.propTypes = {
* - id optional, id that is assigned to the top level dialog node, but not the backdrop
* - variant: See PF4 Modal component's 'variant' property
* - titleIconVariant: See PF4 Modal component's 'titleIconVariant' property
+ * - showClose optional, specifies if 'X' button for closing the dialog is present
*/
class Dialog extends React.Component {
componentDidMount() {
+ // For the scenario that cockpit-storage is used inside anaconda Web UI
+ // We need to know if there is an open dialog in order to create the backdrop effect
+ // on the parent window
+ window.sessionStorage.setItem("cockpit_has_modal", true);
+
// if we used a button to open this, make sure it's not focused anymore
if (document.activeElement)
document.activeElement.blur();
}
+ componentWillUnmount() {
+ window.sessionStorage.setItem("cockpit_has_modal", false);
+ }
+
render() {
let help = null;
let footer = null;
@@ -234,14 +244,13 @@ class Dialog extends React.Component {
;
const error = this.props.error || this.props.static_error;
- const error_alert = error &&
- {error.details};
+ const error_alert = error && ;
return (
undefined}
- showClose={false}
+ showClose={!!this.props.showClose}
id={this.props.id}
isOpen
help={help}
@@ -261,9 +270,10 @@ Dialog.propTypes = {
title: PropTypes.string, // is effectively required, but show_modal_dialog() provides initially no props and resets them later.
body: PropTypes.element, // is effectively required, see above
static_error: PropTypes.string,
+ error: PropTypes.string,
footer: PropTypes.element, // is effectively required, see above
id: PropTypes.string,
- error: PropTypes.object,
+ showClose: PropTypes.bool,
};
/* Create and show a dialog
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-dropdown.tsx b/src/cockpit/389-console/pkg/lib/cockpit-components-dropdown.tsx
new file mode 100644
index 0000000000..4f12a3fbba
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-dropdown.tsx
@@ -0,0 +1,83 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import React, { useState } from 'react';
+import PropTypes from "prop-types";
+
+import { MenuToggle } from "@patternfly/react-core/dist/esm/components/MenuToggle";
+import { Dropdown, DropdownList, DropdownPopperProps } from "@patternfly/react-core/dist/esm/components/Dropdown";
+
+import { EllipsisVIcon } from '@patternfly/react-icons';
+
+/*
+ * A dropdown with a Kebab button, commonly used in Cockpit pages provided as
+ * component so not all pages have to re-invent the wheel.
+ *
+ * isOpen/setIsOpen are optional -- you need to handle the state externally if you render the KebabDropdown in an
+ * "unstable" environment such as a dynamic list. When not given, the dropdown will manage its own state.
+ *
+ * This component expects a list of (non-deprecated!) DropdownItem's, if you
+ * require a separator between DropdownItem's use PatternFly's Divivder
+ * component.
+ */
+export const KebabDropdown = ({ dropdownItems, position = "end", isDisabled = false, toggleButtonId, isOpen, setIsOpen } : {
+ dropdownItems: React.ReactNode,
+ position?: DropdownPopperProps['position'],
+ isDisabled?: boolean,
+ toggleButtonId?: string;
+ isOpen?: boolean, setIsOpen?: React.Dispatch>,
+}) => {
+ const [isKebabOpenInternal, setKebabOpenInternal] = useState(false);
+ const isKebabOpen = isOpen ?? isKebabOpenInternal;
+ const setKebabOpen = setIsOpen ?? setKebabOpenInternal;
+
+ return (
+ setKebabOpen(isOpen)}
+ onSelect={() => setKebabOpen(false)}
+ toggle={(toggleRef) => (
+ setKebabOpen(!isKebabOpen)}
+ isExpanded={isKebabOpen}
+ >
+
+
+ )}
+ isOpen={isKebabOpen}
+ popperProps={{ position }}
+ >
+
+ {dropdownItems}
+
+
+ );
+};
+
+KebabDropdown.propTypes = {
+ dropdownItems: PropTypes.array.isRequired,
+ isDisabled: PropTypes.bool,
+ toggleButtonId: PropTypes.string,
+ position: PropTypes.oneOf(['right', 'left', 'center', 'start', 'end']),
+ isOpen: PropTypes.bool,
+ setIsOpen: PropTypes.func,
+};
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-dynamic-list.jsx b/src/cockpit/389-console/pkg/lib/cockpit-components-dynamic-list.jsx
new file mode 100644
index 0000000000..4cad793d66
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-dynamic-list.jsx
@@ -0,0 +1,143 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button } from "@patternfly/react-core/dist/esm/components/Button";
+import { EmptyState, EmptyStateBody } from "@patternfly/react-core/dist/esm/components/EmptyState";
+import { FormFieldGroup, FormFieldGroupHeader } from "@patternfly/react-core/dist/esm/components/Form";
+import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText";
+
+import './cockpit-components-dynamic-list.scss';
+
+/* Dynamic list with a variable number of rows. Each row is a custom component, usually an input field(s).
+ *
+ * Props:
+ * - emptyStateString
+ * - onChange
+ * - id
+ * - itemcomponent
+ * - formclass (optional)
+ * - options (optional)
+ * - onValidationChange: A handler function which updates the parent's component's validation object.
+ * Its parameter is an array the same structure as 'validationFailed'.
+ * - validationFailed: An array where each item represents a validation error of the corresponding row component index.
+ * A row is strictly mapped to an item of the array by its index.
+ * Example: Let's have a dynamic form, where each row consists of 2 fields: name and email. Then a validation array of
+ * these rows would look like this:
+ * [
+ * { name: "Name must not be empty }, // first row
+ * { }, // second row
+ * { name: "Name cannot contain a number", email: "Email must contain '@'" } // third row
+ * ]
+ */
+export class DynamicListForm extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ list: [],
+ };
+ this.keyCounter = 0;
+ this.removeItem = this.removeItem.bind(this);
+ this.addItem = this.addItem.bind(this);
+ this.onItemChange = this.onItemChange.bind(this);
+ }
+
+ removeItem(idx) {
+ const validationFailedDelta = this.props.validationFailed ? [...this.props.validationFailed] : [];
+ // We also need to remove any error messages which the item (row) may have contained
+ delete validationFailedDelta[idx];
+ this.props.onValidationChange?.(validationFailedDelta);
+
+ this.setState(state => {
+ const items = [...state.list];
+ // keep the list structure, otherwise all the indexes shift and the ID/key mapping gets broken
+ delete items[idx];
+
+ return { list: items };
+ }, () => this.props.onChange(this.state.list));
+ }
+
+ addItem() {
+ this.setState(state => {
+ return { list: [...state.list, { key: this.keyCounter++, ...this.props.default }] };
+ }, () => this.props.onChange(this.state.list));
+ }
+
+ onItemChange(idx, field, value) {
+ this.setState(state => {
+ const items = [...state.list];
+ items[idx][field] = value || null;
+ return { list: items };
+ }, () => this.props.onChange(this.state.list));
+ }
+
+ render () {
+ const { id, label, actionLabel, formclass, emptyStateString, helperText, validationFailed, onValidationChange } = this.props;
+ const dialogValues = this.state;
+ return (
+ {actionLabel}}
+ />
+ } className={"dynamic-form-group " + formclass}>
+ {
+ dialogValues.list.some(item => item !== undefined)
+ ? <>
+ {dialogValues.list.map((item, idx) => {
+ if (item === undefined)
+ return null;
+
+ return React.cloneElement(this.props.itemcomponent, {
+ idx,
+ item,
+ id: id + "-" + idx,
+ key: idx,
+ onChange: this.onItemChange,
+ removeitem: this.removeItem,
+ additem: this.addItem,
+ options: this.props.options,
+ validationFailed: validationFailed && validationFailed[idx],
+ onValidationChange: value => {
+ // Dynamic list consists of multiple rows. Therefore validationFailed object is presented as an array where each item represents a row
+ // Each row/item then consists of key-value pairs, which represent a field name and it's validation error
+ const delta = validationFailed ? [...validationFailed] : [];
+ // Update validation of only a single row
+ delta[idx] = value;
+
+ // If a row doesn't contain any fields with errors anymore, we delete the item of the array
+ // Deleting an item of an array replaces an item with an "empty item".
+ // This guarantees that an array of validation errors maps to the correct rows
+ if (Object.keys(delta[idx]).length == 0)
+ delete delta[idx];
+
+ onValidationChange?.(delta);
+ },
+ });
+ })
+ }
+ {helperText &&
+
+ {helperText}
+
+ }
+ >
+ :
+
+ {emptyStateString}
+
+
+ }
+
+ );
+ }
+}
+
+DynamicListForm.propTypes = {
+ emptyStateString: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ id: PropTypes.string.isRequired,
+ itemcomponent: PropTypes.object.isRequired,
+ formclass: PropTypes.string,
+ options: PropTypes.object,
+ validationFailed: PropTypes.array,
+ onValidationChange: PropTypes.func,
+};
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-dynamic-list.scss b/src/cockpit/389-console/pkg/lib/cockpit-components-dynamic-list.scss
new file mode 100644
index 0000000000..2016fdaaae
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-dynamic-list.scss
@@ -0,0 +1,39 @@
+@import "global-variables";
+
+.dynamic-form-group {
+ .pf-v5-c-empty-state {
+ padding: 0;
+ }
+
+ .pf-v5-c-form__label {
+ // Don't allow labels to wrap
+ white-space: nowrap;
+ }
+
+ .remove-button-group {
+ // Move 'Remove' button the the end of the row
+ grid-column: -1;
+ // Move 'Remove' button to the bottom of the line so as to align with the other form fields
+ display: flex;
+ align-items: flex-end;
+ }
+
+ // Set check to the same height as input widgets and vertically align
+ .pf-v5-c-form__group-control > .pf-v5-c-check {
+ // Set height to the same as inputs
+ // Font height is font size * line height (1rem * 1.5)
+ // Widgets have 5px padding, 1px border (top & bottom): (5 + 1) * 2 = 12
+ // This all equals to 36px
+ block-size: calc(var(--pf-v5-global--FontSize--md) * var(--pf-v5-global--LineHeight--md) + 12px);
+ align-content: center;
+ }
+
+ // We use FormFieldGroup PF component for the nested look and for ability to add buttons to the header
+ // However we want to save space and not add indent to the left so we need to override it
+ .pf-v5-c-form__field-group-body {
+ // Stretch content fully
+ --pf-v5-c-form__field-group-body--GridColumn: 1 / -1;
+ // Reduce padding at the top
+ --pf-v5-c-form__field-group-body--PaddingTop: var(--pf-v5-global--spacer--xs);
+ }
+}
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-empty-state.jsx b/src/cockpit/389-console/pkg/lib/cockpit-components-empty-state.jsx
index 0a78d0530f..82d7b97eb9 100644
--- a/src/cockpit/389-console/pkg/lib/cockpit-components-empty-state.jsx
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-empty-state.jsx
@@ -14,7 +14,7 @@
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
- * along with Cockpit; If not, see .
+ * along with Cockpit; If not, see .
*/
import React from "react";
@@ -24,7 +24,7 @@ import { EmptyStateActions, EmptyState, EmptyStateBody, EmptyStateFooter, EmptyS
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
import "./cockpit-components-empty-state.css";
-export const EmptyStatePanel = ({ title, paragraph, loading, icon, action, isActionInProgress, onAction, secondary, headingLevel, titleSize }) => {
+export const EmptyStatePanel = ({ title, paragraph, loading = false, icon, action, isActionInProgress = false, onAction, actionVariant = "primary", secondary, headingLevel = "h1" }) => {
const slimType = title || paragraph ? "" : "slim";
return (
@@ -34,7 +34,7 @@ export const EmptyStatePanel = ({ title, paragraph, loading, icon, action, isAct
{(action || secondary) &&
{ action && (typeof action == "string"
- ?
@@ -47,17 +47,12 @@ export const EmptyStatePanel = ({ title, paragraph, loading, icon, action, isAct
EmptyStatePanel.propTypes = {
loading: PropTypes.bool,
- icon: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+ icon: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.func]),
title: PropTypes.string,
paragraph: PropTypes.node,
action: PropTypes.node,
+ actionVariant: PropTypes.string,
isActionInProgress: PropTypes.bool,
onAction: PropTypes.func,
secondary: PropTypes.node,
};
-
-EmptyStatePanel.defaultProps = {
- headingLevel: "h1",
- titleSize: "lg",
- isActionInProgress: false,
-};
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-file-autocomplete.jsx b/src/cockpit/389-console/pkg/lib/cockpit-components-file-autocomplete.jsx
index 134fea19af..f09002ba05 100644
--- a/src/cockpit/389-console/pkg/lib/cockpit-components-file-autocomplete.jsx
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-file-autocomplete.jsx
@@ -14,7 +14,7 @@
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
- * along with Cockpit; If not, see .
+ * along with Cockpit; If not, see .
*/
import cockpit from "cockpit";
@@ -34,7 +34,7 @@ export class FileAutoComplete extends React.Component {
isOpen: false,
value: this.props.value || null,
};
- this.updateFiles(props.value || '/');
+
this.typeaheadInputValue = "";
this.allowFilesUpdate = true;
this.updateFiles = this.updateFiles.bind(this);
@@ -43,7 +43,7 @@ export class FileAutoComplete extends React.Component {
this.clearSelection = this.clearSelection.bind(this);
this.onCreateOption = this.onCreateOption.bind(this);
- this.debouncedChange = debounce(300, (value) => {
+ this.onPathChange = (value) => {
if (!value) {
this.clearSelection();
return;
@@ -80,7 +80,9 @@ export class FileAutoComplete extends React.Component {
return this.updateFiles(parentDir + '/');
}
}
- });
+ };
+ this.debouncedChange = debounce(300, this.onPathChange);
+ this.onPathChange(this.state.value);
}
componentWillUnmount() {
@@ -115,7 +117,8 @@ export class FileAutoComplete extends React.Component {
channel.addEventListener("message", (ev, data) => {
const item = JSON.parse(data);
- if (item && item.path && item.event == 'present') {
+ if (item && item.path && item.event == 'present' &&
+ (!this.props.onlyDirectories || item.type == 'directory')) {
item.path = item.path + (item.type == 'directory' ? '/' : '');
results.push(item);
}
@@ -140,7 +143,7 @@ export class FileAutoComplete extends React.Component {
}
if (error || !this.state.value)
- this.props.onChange('');
+ this.props.onChange('', error);
if (!error)
this.setState({ displayFiles: listItems, directory });
@@ -160,7 +163,7 @@ export class FileAutoComplete extends React.Component {
value: null,
isOpen: false
});
- this.props.onChange('');
+ this.props.onChange('', null);
}
render() {
@@ -182,7 +185,7 @@ export class FileAutoComplete extends React.Component {
onSelect={(_, value) => {
this.setState({ value, isOpen: false });
this.debouncedChange(value);
- this.props.onChange(value || '');
+ this.props.onChange(value || '', null);
}}
onToggle={this.onToggle}
onClear={this.clearSelection}
@@ -201,10 +204,12 @@ FileAutoComplete.propTypes = {
placeholder: PropTypes.string,
superuser: PropTypes.string,
isOptionCreatable: PropTypes.bool,
+ onlyDirectories: PropTypes.bool,
onChange: PropTypes.func,
value: PropTypes.string,
};
FileAutoComplete.defaultProps = {
isOptionCreatable: false,
+ onlyDirectories: false,
onChange: () => '',
};
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-firewalld-request.jsx b/src/cockpit/389-console/pkg/lib/cockpit-components-firewalld-request.jsx
index 517b8bb9db..41530b55d2 100644
--- a/src/cockpit/389-console/pkg/lib/cockpit-components-firewalld-request.jsx
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-firewalld-request.jsx
@@ -14,7 +14,7 @@
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
- * along with Cockpit; If not, see .
+ * along with Cockpit; If not, see .
*/
import React, { useState } from 'react';
import { Alert, AlertActionCloseButton, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-form-helper.tsx b/src/cockpit/389-console/pkg/lib/cockpit-components-form-helper.tsx
new file mode 100644
index 0000000000..a5a286bb46
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-form-helper.tsx
@@ -0,0 +1,51 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2023 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import React from "react";
+
+import { FormHelperText } from "@patternfly/react-core/dist/esm/components/Form/index.js";
+import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText";
+
+export const FormHelper = ({ helperText, helperTextInvalid, variant, icon, fieldId } :
+ {
+ helperText?: string | null | undefined,
+ helperTextInvalid?: string | null | undefined,
+ variant?: "error" | "default" | "indeterminate" | "warning" | "success",
+ icon?: string,
+ fieldId?: string,
+ }
+) => {
+ const formHelperVariant = variant || (helperTextInvalid ? "error" : "default");
+
+ if (!(helperText || helperTextInvalid))
+ return null;
+
+ return (
+
+
+
+ {formHelperVariant === "error" ? helperTextInvalid : helperText}
+
+
+
+ );
+};
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-inline-notification.tsx b/src/cockpit/389-console/pkg/lib/cockpit-components-inline-notification.tsx
new file mode 100644
index 0000000000..1503f52bdb
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-inline-notification.tsx
@@ -0,0 +1,83 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2016 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import cockpit from 'cockpit';
+
+import { Alert, AlertActionCloseButton, AlertProps } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import './cockpit-components-inline-notification.css';
+
+const _ = cockpit.gettext;
+
+export const InlineNotification = ({ text, detail, type = "danger", onDismiss, isInline = true, isLiveRegion = false }: {
+ text: string;
+ detail?: string;
+ type?: AlertProps["variant"];
+ onDismiss?: (ev?: Event) => void;
+ isInline?: boolean;
+ isLiveRegion?: boolean;
+}) => {
+ const [isDetail, setIsDetail] = useState(false);
+
+ const detailButton = (detail &&
+
+ );
+
+ return (
+ {text} {detailButton} >}
+ { ...onDismiss && { actionClose: } }>
+ {isDetail && (
: dialogErrorDetail }
+
+ );
+};
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-install-dialog.jsx b/src/cockpit/389-console/pkg/lib/cockpit-components-install-dialog.jsx
index 0d933ae5f5..921247d44e 100644
--- a/src/cockpit/389-console/pkg/lib/cockpit-components-install-dialog.jsx
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-install-dialog.jsx
@@ -14,7 +14,7 @@
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
- * along with Cockpit; If not, see .
+ * along with Cockpit; If not, see .
*/
import cockpit from "cockpit";
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-listing-panel.jsx b/src/cockpit/389-console/pkg/lib/cockpit-components-listing-panel.jsx
index 3f2b784b81..4e0a836884 100644
--- a/src/cockpit/389-console/pkg/lib/cockpit-components-listing-panel.jsx
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-listing-panel.jsx
@@ -14,7 +14,7 @@
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
- * along with Cockpit; If not, see .
+ * along with Cockpit; If not, see .
*/
import PropTypes from 'prop-types';
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-listing-panel.scss b/src/cockpit/389-console/pkg/lib/cockpit-components-listing-panel.scss
index bd53cd986d..c258ae6975 100644
--- a/src/cockpit/389-console/pkg/lib/cockpit-components-listing-panel.scss
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-listing-panel.scss
@@ -18,8 +18,7 @@
order: 1;
}
- // FIXME: https://github.com/patternfly/patternfly-react/pull/9135
- .pf-c-tab-content {
+ .pf-v5-c-tab-content {
order: 3;
flex-basis: 100%;
}
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-logs-panel.jsx b/src/cockpit/389-console/pkg/lib/cockpit-components-logs-panel.jsx
index c960d6da31..4fe66ba50a 100644
--- a/src/cockpit/389-console/pkg/lib/cockpit-components-logs-panel.jsx
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-logs-panel.jsx
@@ -14,7 +14,7 @@
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
- * along with Cockpit; If not, see .
+ * along with Cockpit; If not, see .
*/
import cockpit from "cockpit";
@@ -55,7 +55,7 @@ export class JournalOutput {
}
// only consider enter button for keyboard events
- if (ev.type === 'keypress' && ev.key !== "Enter")
+ if (ev.type === 'KeyDown' && ev.key !== "Enter")
return;
cockpit.jump("system/logs#/" + cursor + "?parent_options=" + JSON.stringify(this.search_options));
@@ -78,7 +78,7 @@ export class JournalOutput {
+
+ }
{error_password &&
@@ -131,8 +166,20 @@ export const PasswordFormFields = ({
{password_confirm_label &&
- { setConfirmPassword(value); change("password_confirm", value) }} />
+
+
+ { setConfirmPassword(value); change("password_confirm", value) }} />
+
+
+
+
+ }
>
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-password.scss b/src/cockpit/389-console/pkg/lib/cockpit-components-password.scss
index 99f7725621..e1d2d44906 100644
--- a/src/cockpit/389-console/pkg/lib/cockpit-components-password.scss
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-password.scss
@@ -1,5 +1,9 @@
+@import "global-variables";
+@import "@patternfly/patternfly/utilities/Text/text.scss";
+
.ct-password-strength-meter {
grid-gap: var(--pf-v5-global--spacer--xs);
+ inline-size: var(--pf-v5-global--spacer--2xl);
.pf-v5-c-progress__description, .pf-v5-c-progress__status {
display: none;
diff --git a/src/cockpit/389-console/pkg/lib/cockpit-components-plot.jsx b/src/cockpit/389-console/pkg/lib/cockpit-components-plot.jsx
index c1f075a858..0849f737ae 100644
--- a/src/cockpit/389-console/pkg/lib/cockpit-components-plot.jsx
+++ b/src/cockpit/389-console/pkg/lib/cockpit-components-plot.jsx
@@ -14,7 +14,7 @@
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
- * along with Cockpit; If not, see .
+ * along with Cockpit; If not, see .
*/
import cockpit from "cockpit";
@@ -23,7 +23,9 @@ import React, { useState, useRef, useLayoutEffect } from 'react';
import { useEvent } from "hooks.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
-import { Dropdown, DropdownItem, DropdownSeparator, DropdownToggle } from '@patternfly/react-core/dist/esm/deprecated/components/Dropdown/index.js';
+import { Dropdown, DropdownItem, DropdownList } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
+import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
+import { MenuToggle } from '@patternfly/react-core/dist/esm/components/MenuToggle/index.js';
import { AngleLeftIcon, AngleRightIcon, SearchMinusIcon } from '@patternfly/react-icons';
@@ -254,22 +256,32 @@ export const ZoomControls = ({ plot_state }) => {
if (!zoom_state)
return null;
+ const dropdownItems = [
+ { zoom_state.goto_now(); setIsOpen(false) }}>
+ {_("Go to now")}
+ ,
+ ,
+ range_item(5 * 60, _("5 minutes")),
+ range_item(60 * 60, _("1 hour")),
+ range_item(6 * 60 * 60, _("6 hours")),
+ range_item(24 * 60 * 60, _("1 day")),
+ range_item(7 * 24 * 60 * 60, _("1 week"))
+ ];
+
return (
-
+
{format_range(zoom_state.x_range)}}
- dropdownItems={[
- { zoom_state.goto_now(); setIsOpen(false) }}>
- {_("Go to now")}
- ,
- ,
- range_item(5 * 60, _("5 minutes")),
- range_item(60 * 60, _("1 hour")),
- range_item(6 * 60 * 60, _("6 hours")),
- range_item(24 * 60 * 60, _("1 day")),
- range_item(7 * 24 * 60 * 60, _("1 week"))
- ]} />
+ toggle={(toggleRef) => (
+ setIsOpen(!isOpen)} isExpanded={isOpen}>
+ {format_range(zoom_state.x_range)}
+
+ )}
+ >
+
+ {dropdownItems}
+
+
{ "\n" }
);
let ntp_status = null;
@@ -617,12 +593,9 @@ function ChangeSystimeBody({ state, errors, change }) {
change("manual_date", d)}
value={manual_date}
appendTo={() => document.body} />
@@ -673,7 +646,7 @@ function change_systime_dialog(server_time, timezone) {
let errors = { };
function get_current_time() {
- state.manual_date = server_time.format();
+ state.manual_date = server_time.utc_fake_now.toISOString().split("T")[0];
const minutes = server_time.utc_fake_now.getUTCMinutes();
// normalize to two digits
diff --git a/src/cockpit/389-console/pkg/lib/service.js b/src/cockpit/389-console/pkg/lib/service.js
index 3ef1878f78..fb9e0dcd7b 100644
--- a/src/cockpit/389-console/pkg/lib/service.js
+++ b/src/cockpit/389-console/pkg/lib/service.js
@@ -223,7 +223,7 @@ export function proxy(name, kind) {
refresh();
}
- /* HACK - https://bugs.freedesktop.org/show_bug.cgi?id=69575
+ /* HACK - https://github.com/systemd/systemd/issues/570#issuecomment-125334529
*
* We need to explicitly get new property values when getting
* a UnitNew signal since UnitNew doesn't carry them.
diff --git a/src/cockpit/389-console/pkg/lib/ssh-add-key.sh b/src/cockpit/389-console/pkg/lib/ssh-add-key.sh
new file mode 100755
index 0000000000..0e6c20930b
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/ssh-add-key.sh
@@ -0,0 +1,25 @@
+#! /bin/sh
+
+set -euf
+
+d=$HOME/.ssh
+p=${2:-authorized_keys}
+f=$d/$p
+
+if ! test -f "$f"; then
+ # shellcheck disable=SC2174 # yes, we know that -m only applies to the deepest directory
+ mkdir -m 700 -p "$d"
+ touch "$f"
+ chmod 600 "$f"
+fi
+
+while read l; do
+ if [ "$l" = "$1" ]; then
+ exit 0
+ fi
+done <"$f"
+
+# Add newline if necessary
+! test -s "$f" || tail -c1 < "$f" | read -r _ || echo >> "$f"
+
+echo "$1" >>"$f"
diff --git a/src/cockpit/389-console/pkg/lib/ssh-show-default-key.sh b/src/cockpit/389-console/pkg/lib/ssh-show-default-key.sh
new file mode 100755
index 0000000000..a7198b3f3d
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/ssh-show-default-key.sh
@@ -0,0 +1,16 @@
+#! /bin/sh
+
+set -euf
+
+# Print the name of default key, if any.
+
+for f in id_dsa id_ecdsa id_ecdsa_sk id_ed25519 id_ed25519_sk id_rsa; do
+ p=$HOME/.ssh/$f
+ if test -f "$p"; then
+ echo "$p"
+ if ! ssh-keygen -y -P "" -f "$p" >/dev/null 2>/dev/null; then
+ echo "encrypted"
+ fi
+ exit 0
+ fi
+done
diff --git a/src/cockpit/389-console/pkg/lib/superuser.js b/src/cockpit/389-console/pkg/lib/superuser.js
index 5753b2ccbd..c2e1c9bbe4 100644
--- a/src/cockpit/389-console/pkg/lib/superuser.js
+++ b/src/cockpit/389-console/pkg/lib/superuser.js
@@ -14,7 +14,7 @@
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
- * along with Cockpit; If not, see .
+ * along with Cockpit; If not, see .
*/
import cockpit from "cockpit";
diff --git a/src/cockpit/389-console/pkg/lib/table.css b/src/cockpit/389-console/pkg/lib/table.css
index fe66a09ce1..63277cfb97 100644
--- a/src/cockpit/389-console/pkg/lib/table.css
+++ b/src/cockpit/389-console/pkg/lib/table.css
@@ -106,19 +106,9 @@
}
}
-/*
- * Fix up table row hovering.
- *
- * When you hover over table rows it's because they're clickable.
- */
-.table-hover > tbody > tr > td,
-.table-hover > tbody > tr > th {
- cursor: pointer;
-}
-
-.table-hover > tbody > tr:hover > td,
-.table-hover > tbody > tr:hover > th {
- /* PF3 uses a light blue; we have to force the override for hover colors */
+.pf-v5-c-table__tr.pf-m-clickable:hover > td,
+.pf-v5-c-table__tr.pf-m-clickable:hover > th {
+ /* PF5 has no hover background color; we have to force the override for hover colors */
background-color: var(--ct-color-list-hover-bg) !important;
color: var(--ct-color-list-hover-text) !important;
}
diff --git a/src/cockpit/389-console/pkg/lib/test-path.ts b/src/cockpit/389-console/pkg/lib/test-path.ts
new file mode 100644
index 0000000000..26a5a5ebdc
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/test-path.ts
@@ -0,0 +1,57 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import { dirname, basename } from "./cockpit-path";
+import QUnit from "qunit-tests";
+
+QUnit.test("dirname", function (assert) {
+ const checks = [
+ ["foo", "."],
+ ["/", "/"],
+ ["foo/bar", "foo"],
+ ["/foo", "/"],
+ ["foo///", "."],
+ ["/foo///", "/"],
+ ["////", "/"],
+ ["//foo///", "/"],
+ ["///foo///bar///", "///foo"],
+ ];
+
+ assert.expect(checks.length);
+ for (let i = 0; i < checks.length; i++) {
+ assert.strictEqual(dirname(checks[i][0]), checks[i][1],
+ "dirname(" + checks[i][0] + ") = " + checks[i][1]);
+ }
+});
+
+QUnit.test("basename", function (assert) {
+ const checks = [
+ ["foo", "foo"],
+ ["bar/foo/", "foo"],
+ ["//bar//foo///", "foo"],
+ ];
+
+ assert.expect(checks.length);
+ for (let i = 0; i < checks.length; i++) {
+ assert.strictEqual(basename(checks[i][0]), checks[i][1],
+ "basename(" + checks[i][0] + ") = " + checks[i][1]);
+ }
+});
+
+QUnit.start();
diff --git a/src/cockpit/389-console/pkg/lib/timeformat.ts b/src/cockpit/389-console/pkg/lib/timeformat.ts
new file mode 100644
index 0000000000..1a7e1a87e9
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/timeformat.ts
@@ -0,0 +1,83 @@
+/* Wrappers around Intl.DateTimeFormat which use Cockpit's current locale, and define a few standard formats.
+ * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat
+ *
+ * Time stamps are given in milliseconds since the epoch.
+ */
+import cockpit from "cockpit";
+
+const _ = cockpit.gettext;
+
+// this needs to be dynamic, as some pages don't initialize cockpit.language right away
+export const dateFormatLang = (): string => cockpit.language.replace('_', '-');
+
+type Time = Date | number;
+
+// general Intl.DateTimeFormat formatter object
+export const formatter = (options?: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(dateFormatLang(), options);
+
+// common formatters; try to use these as much as possible, for UI consistency
+// 07:41 AM
+export const time = (t: Time): string => formatter({ timeStyle: "short" }).format(t);
+// 7:41:26 AM
+export const timeSeconds = (t: Time): string => formatter({ timeStyle: "medium" }).format(t);
+// June 30, 2021
+export const date = (t: Time): string => formatter({ dateStyle: "long" }).format(t);
+// 06/30/2021
+export const dateShort = (t: Time): string => formatter().format(t);
+// Jun 30, 2021, 7:41 AM
+export const dateTime = (t: Time): string => formatter({ dateStyle: "medium", timeStyle: "short" }).format(t);
+// Jun 30, 2021, 7:41 AM if `t` is in UTC format
+export const dateTimeUTC = (t: Time): string => formatter({ dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(t);
+// Jun 30, 2021, 7:41:23 AM
+export const dateTimeSeconds = (t: Time): string => formatter({ dateStyle: "medium", timeStyle: "medium" }).format(t);
+// Jun 30, 7:41 AM
+export const dateTimeNoYear = (t: Time): string => formatter({ month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(t);
+// Wednesday, June 30, 2021
+export const weekdayDate = (t: Time): string => formatter({ dateStyle: "full" }).format(t);
+
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/format
+const units: { name: Intl.RelativeTimeFormatUnit, max: number }[] = [
+ { name: "second", max: 60 },
+ { name: "minute", max: 3600 },
+ { name: "hour", max: 86400 },
+ { name: "day", max: 86400 * 7 },
+ { name: "week", max: 86400 * 30 },
+ { name: "month", max: 86400 * 365 },
+ { name: "year", max: Infinity },
+];
+
+// "1 hour ago" for past times, "in 1 hour" for future times
+export function distanceToNow(t: Time): string {
+ // Calculate the difference in seconds between the given date and the current date
+ const t_timestamp = t?.valueOf() ?? t;
+ const secondsDiff = Math.round((t_timestamp - Date.now()) / 1000);
+
+ // special case for < 1 minute, like date-fns
+ // we don't constantly re-render pages and there are delays, so seconds is too precise
+ if (secondsDiff <= 0 && secondsDiff > -60)
+ return _("less than a minute ago");
+ if (secondsDiff > 0 && secondsDiff < 60)
+ return _("in less than a minute");
+
+ // find the appropriate unit based on the seconds difference
+ const unitIndex = units.findIndex(u => u.max > Math.abs(secondsDiff));
+ // get the divisor to convert seconds to the appropriate unit
+ const divisor = unitIndex ? units[unitIndex - 1].max : 1;
+
+ const formatter = new Intl.RelativeTimeFormat(dateFormatLang(), { numeric: "auto" });
+ return formatter.format(Math.round(secondsDiff / divisor), units[unitIndex].name);
+}
+
+/***
+ * sorely missing from Intl: https://github.com/tc39/ecma402/issues/6
+ * based on https://github.com/unicode-cldr/cldr-core/blob/master/supplemental/weekData.json#L59
+ * However, we don't have translations for most locales, and cockpit.language does not even contain
+ * the country in most cases, so this is just an approximation.
+ * Most locales start the week on Monday (day 1), so default to that and enumerate the others.
+ */
+
+const first_dow_sun = ['en', 'ja', 'ko', 'pt', 'pt_BR', 'sv', 'zh_CN', 'zh_TW'];
+
+export function firstDayOfWeek(): number {
+ return first_dow_sun.indexOf(cockpit.language) >= 0 ? 0 : 1;
+}
diff --git a/src/cockpit/389-console/pkg/lib/utils.tsx b/src/cockpit/389-console/pkg/lib/utils.tsx
new file mode 100644
index 0000000000..d49b75fc3e
--- /dev/null
+++ b/src/cockpit/389-console/pkg/lib/utils.tsx
@@ -0,0 +1,85 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2018 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import React from "react";
+
+import cockpit from "cockpit";
+
+export function fmt_to_fragments(format: string, ...args: React.ReactNode[]) {
+ const fragments = format.split(/(\$[0-9]+)/g).map(part => {
+ if (part[0] == "$") {
+ return args[parseInt(part.slice(1))]; // placeholder, from `args`
+ } else
+ return part; // literal string content
+ });
+
+ return React.createElement(React.Fragment, { }, ...fragments);
+}
+
+/**
+ * Checks if a JsonValue is a JsonObject, and acts as a type guard.
+ *
+ * This function produces correct results for any possible JsonValue, and also
+ * for undefined. If you pass other types of values to this function it may
+ * return an incorrect result (ie: it doesn't check deeply, so anything that
+ * looks like a "simple object" will pass the check).
+ */
+export function is_json_dict(value: cockpit.JsonValue | undefined): value is cockpit.JsonObject {
+ return value?.constructor === Object;
+}
+
+function try_fields(
+ dict: cockpit.JsonObject, fields: (string | undefined)[], def: cockpit.JsonValue
+): cockpit.JsonValue {
+ for (const field of fields)
+ if (field && field in dict)
+ return dict[field];
+ return def;
+}
+
+/**
+ * Get an entry from a manifest's ".config[config_name]" field.
+ *
+ * This can either be a direct value, e.g.
+ *
+ * "config": { "color": "yellow" }
+ *
+ * Or an object indexed by any value in "matches". Commonly these are fields
+ * from os-release(5) like PLATFORM_ID or ID; e.g.
+ *
+ * "config": {
+ * "fedora": { "color": "blue" },
+ * "platform:el9": { "color": "red" }
+ * }
+ */
+export function get_manifest_config_matchlist(
+ manifest_name: string, config_name: string, default_value: cockpit.JsonValue, matches: (string | undefined)[]
+): cockpit.JsonValue {
+ const config = cockpit.manifests[manifest_name]?.config;
+
+ if (is_json_dict(config)) {
+ const val = config[config_name];
+ if (is_json_dict(val))
+ return try_fields(val, matches, default_value);
+ else
+ return val !== undefined ? val : default_value;
+ } else {
+ return default_value;
+ }
+}