SHA256
3
0
forked from pool/cockpit

Added packagekit-single-install.patch functionality

This commit is contained in:
Miika Alikirri 2025-02-21 00:24:59 +02:00
parent ba33234158
commit 783d2e3bb1
3 changed files with 381 additions and 0 deletions

View File

@ -1,3 +1,10 @@
-------------------------------------------------------------------
Thu Feb 20 22:22:32 UTC 2025 - Miika Alikirri <miika.alikirri@suse.com>
- Add functionality to cockpit-packagekit that allows selecting what updates
should be applied
- Added packagekit-single-install.patch file that adds this functionality
-------------------------------------------------------------------
Fri Feb 7 09:24:33 UTC 2025 - Alice Brooks <alice.brooks@suse.com>

View File

@ -76,6 +76,7 @@ Patch108: 0007-Remove-DynamicUser-setting-as-these-conflict-with-re.patch
Patch103: 0004-leap-gnu18-removal.patch
Patch104: selinux_libdir.patch
Patch105: fix-libexecdir.patch
Patch106: packagekit-single-install.patch
Patch201: remove_rh_links.patch
@ -212,6 +213,7 @@ BuildRequires: python3-pytest-timeout
%patch -P 3 -p1
%patch -P 4 -p1
%patch -P 5 -p1
%patch -P 106 -p1
# SLE Micro specific patches

View File

@ -0,0 +1,372 @@
From 291aba8127cb3e44e82b164e75d1063f3ae7fe9c Mon Sep 17 00:00:00 2001
From: Miika Alikirri <miika.alikirri@suse.com>
Date: Wed, 29 Jan 2025 14:04:39 +0200
Subject: pkg/pacagekit: Update individual packages
Ability to select individual packages allows more control for updates.
The exact behavior is distrobution specific. For example, on tumbleweed
packagekit backend will ignore the list of packages and run "zypper dup"
instead.
The selection of individual packages is implemented by using a context
provider and a reducer to make the UI updates snappy. A more naive
approach that requires rendering the whole list of packages will freeze
up the UI for multiple seconds when there's hundreds of packages. And
tens of seconds when there are thousands of packages.
---
pkg/packagekit/updates.jsx | 239 ++++++++++++++++++++++++++++++++----
pkg/packagekit/updates.scss | 6 +-
2 files changed, 219 insertions(+), 26 deletions(-)
diff --git a/pkg/packagekit/updates.jsx b/pkg/packagekit/updates.jsx
index 1feb57a0f..4fb68847f 100644
--- a/pkg/packagekit/updates.jsx
+++ b/pkg/packagekit/updates.jsx
@@ -77,6 +77,7 @@ import * as python from "python.js";
import callTracerScript from './callTracer.py';
import "./updates.scss";
+import { Checkbox } from '@patternfly/react-core';
const _ = cockpit.gettext;
@@ -90,6 +91,7 @@ const UPDATES = {
ALL: 0,
SECURITY: 1,
KPATCHES: 2,
+ SELECTED: 3,
};
function init() {
@@ -114,6 +116,196 @@ function init() {
PK_STATUS_LOG_STRINGS[PK.Enum.STATUS_SIGCHECK] = _("Verified");
}
+/**
+ * @typedef SelecetedState
+ * @type {object}
+ * @property {boolean} allSelected - Are all items selected
+ * @property {Object.<string, boolean>} selected - (Un)selected items.
+ * If allSelected is set, this refers to unselecetd
+ */
+
+/**
+ * @typedef SelecetedAction
+ * @type {object}
+ * @property {"ADD" | "REMOVE" | "ALL" | "NONE"} type - Type of reducer action
+ * @property {string=} id - Added removed item, only used by "ADD" and "REMOVE"
+ */
+
+const SelectedContext = React.createContext({selected: {}, allSelected: true});
+
+const SelectedStore = props => {
+
+ /**
+ * @argument {SelecetedState} state
+ * @argument {SelecetedAction} action
+ */
+ const reducer = (state, action) => {
+ switch (action.type) {
+ case "ADD":
+ if (action.id) {
+ if (state.allSelected)
+ delete state.selected[action.id];
+ else
+ state.selected[action.id] = true;
+ }
+ break;
+ case "REMOVE":
+ if (action.id) {
+ if (state.allSelected)
+ state.selected[action.id] = true;
+ else
+ delete state.selected[action.id];
+ }
+ break;
+ case "ALL":
+ state.allSelected = true;
+ state.selected = {};
+ break;
+ case "NONE":
+ state.allSelected = false;
+ state.selected = {};
+ break;
+ default:
+ break;
+ }
+
+ return {...state};
+ }
+
+ const [state, dispatch] = React.useReducer(reducer, {selected: {}, allSelected: true});
+
+ return <SelectedContext.Provider value={{ state, dispatch }} {...props} />;
+};
+
+/**
+ * @returns {{state: SelecetedState, dispatch: (arg: SelecetedAction) => void}}
+ */
+export const useSelected = () => React.useContext(SelectedContext);
+
+/**
+ * @param {{
+ * onClick: (state: SelecetedState) => void
+ * updates: string[],
+ * num_updates: number
+ * }} props;
+ */
+const SelectedButton = (props) => {
+ const { state, dispatch } = useSelected();
+ const {
+ onClick,
+ updates,
+ num_updates,
+ } = props;
+
+
+ const buttonText = () => {
+ if (state.allSelected && Object.keys(state.selected).length == 0 ||
+ !state.allSelected && Object.keys(state.selected).length == num_updates)
+ return _("Install all updates");
+
+ const selectLen = calculateSelected(updates, state).length;
+ return `${_("Install selected updates")} (${selectLen})`;
+ }
+
+ return (
+ <Button isDisabled={calculateSelected(updates, state).length === 0} id="install-all" variant="primary" onClick={ () => {onClick(state); dispatch({type: "ALL"})} }>
+ {buttonText()}
+ </Button>
+ );
+}
+
+const SelectedAllButton = (props) => {
+ const { state, dispatch } = useSelected();
+
+ const dispatchSelect = () => {
+ if (state.allSelected) {
+ dispatch({type: "NONE"});
+ } else {
+ dispatch({type: "ALL"});
+ }
+ }
+
+ return (
+ <Button id="install-selected" variant="secondary" onClick={ () => dispatchSelect() }>
+ {state.allSelected ? _("Unselect all") : _("Select all") }
+ </Button>
+ );
+}
+
+const SelectedSwitch = (props) => {
+ const { state, dispatch } = useSelected();
+
+ const dispatchChecked = checked => {
+ if (checked) {
+ dispatch({type: "ADD", id: props.id});
+ } else {
+ dispatch({type: "REMOVE", id: props.id});
+ }
+ }
+
+ const isChecked = () => {
+ if (state.allSelected) {
+ return !!!state.selected[props.id];
+ } else {
+ return !!state.selected[props.id];
+ }
+ }
+
+ return (
+ <Checkbox aria-label="select-update-checkbox" isChecked={isChecked()} id={`selectable-${props.id}`} onChange={(_event, checked) => dispatchChecked(checked)} />
+ );
+}
+
+/**
+ * @param {{
+* updates: string[],
+* }} props;
+*/
+const WebConsoleRestartWarn = (props) => {
+ const { state } = useSelected();
+
+ if (calculateSelected(props.updates, state).findIndex((value) => value.includes("cockpit-ws")) === -1)
+ return null;
+
+ return (
+ <Flex flex={{ default: 'inlineFlex' }} className="cockpit-update-warning">
+ <FlexItem>
+ <ExclamationTriangleIcon className="ct-icon-exclamation-triangle cockpit-update-warning-icon" />
+ <strong className="cockpit-update-warning-text">
+ <span className="pf-screen-reader">{_("Danger alert:")}</span>
+ {_("Web Console will restart")}
+ </strong>
+ </FlexItem>
+ <FlexItem>
+ <Popover aria-label="More information popover"
+ bodyContent={_("When the Web Console is restarted, you will no longer see progress information. However, the update process will continue in the background. Reconnect to continue watching the update process.")}>
+ <Button variant="link" isInline>{_("More info...")}</Button>
+ </Popover>
+ </FlexItem>
+ </Flex>
+ );
+}
+
+/**
+ * @param {string[]} allIds
+ * @param {SelecetedState} state
+ * @returns {string[]}
+ */
+function calculateSelected(allIds, state) {
+ const selected = Object.keys(state.selected);
+
+ if (!state.allSelected) {
+ return selected;
+ }
+
+ if (selected.length === 0) {
+ return allIds;
+ }
+
+ return allIds.filter((id) => !!!state.selected[id]);
+}
+
+
// parse CVEs from an arbitrary text (changelog) and return URL array
function parseCVEs(text) {
if (!text)
@@ -398,6 +590,7 @@ function updateItem(remarkable, info, pkgNames, key) {
{ title: <TableText wrapModifier="truncate">{info.version}</TableText>, props: { className: "version" } },
{ title: <TableText wrapModifier="nowrap">{type}</TableText>, props: { className: "type" } },
{ title: descriptionFirstLine, props: { className: "changelog" } },
+ { title: <SelectedSwitch id={ key }/>, props: { className: "select-update" } },
],
props: {
key,
@@ -448,6 +641,7 @@ const UpdatesList = ({ updates }) => {
{ title: _("Version"), transforms: [cellWidth(15)] },
{ title: _("Severity"), transforms: [cellWidth(15)] },
{ title: _("Details"), transforms: [cellWidth(30)] },
+ { title: _("Select update") },
]}
rows={update_ids.map(id => updateItem(remarkable, updates[id], packageNames[id].sort((a, b) => a.name > b.name), id))} />
);
@@ -933,25 +1127,12 @@ class CardsPage extends React.Component {
id: "available-updates",
title: _("Available updates"),
actions: (<div className="pk-updates--header--actions">
- {this.props.cockpitUpdate &&
- <Flex flex={{ default: 'inlineFlex' }} className="cockpit-update-warning">
- <FlexItem>
- <ExclamationTriangleIcon className="ct-icon-exclamation-triangle cockpit-update-warning-icon" />
- <strong className="cockpit-update-warning-text">
- <span className="pf-screen-reader">{_("Danger alert:")}</span>
- {_("Web Console will restart")}
- </strong>
- </FlexItem>
- <FlexItem>
- <Popover aria-label="More information popover"
- bodyContent={_("When the Web Console is restarted, you will no longer see progress information. However, the update process will continue in the background. Reconnect to continue watching the update process.")}>
- <Button variant="link" isInline>{_("More info...")}</Button>
- </Popover>
- </FlexItem>
- </Flex>}
+ <WebConsoleRestartWarn updates={Object.keys(this.props.updates)} />
{this.props.applyKpatches}
{this.props.applySecurity}
{this.props.applyAll}
+ {this.props.applySelected}
+ {this.props.applySelectAll}
</div>),
containsList: true,
body: <UpdatesList updates={this.props.updates} />
@@ -1325,13 +1506,19 @@ class OsUpdates extends React.Component {
});
}
- applyUpdates(type) {
+ /**
+ * @param {SelecetedState=} selected
+ */
+ applyUpdates(type, selected) {
let ids = Object.keys(this.state.updates);
if (type === UPDATES.SECURITY)
ids = ids.filter(id => this.state.updates[id].severity === PK.Enum.INFO_SECURITY);
if (type === UPDATES.KPATCHES) {
ids = ids.filter(id => isKpatchPackage(this.state.updates[id].name));
}
+ if (type === UPDATES.SELECTED && selected) {
+ ids = calculateSelected(ids, selected);
+ }
PK.transaction()
.then(transactionPath => {
@@ -1354,7 +1541,7 @@ class OsUpdates extends React.Component {
}
renderContent() {
- let applySecurity, applyKpatches, applyAll;
+ let applySecurity, applyKpatches, applyAll, applySelected, applySelectAll;
/* On unregistered RHEL systems we need some heuristics: If the "main" OS repos (which provide coreutils) require
* a subscription, then point this out and don't show available updates, even if there are some auxiliary
@@ -1409,12 +1596,8 @@ class OsUpdates extends React.Component {
const num_kpatches = count_kpatch_updates(this.state.updates);
const highest_severity = find_highest_severity(this.state.updates);
- applyAll = (
- <Button id={num_updates == num_security_updates ? "install-security" : "install-all"} variant="primary" onClick={ () => this.applyUpdates(UPDATES.ALL) }>
- { num_updates == num_security_updates
- ? _("Install security updates")
- : _("Install all updates") }
- </Button>);
+ applySelected = <SelectedButton updates={Object.keys(this.state.updates)} num_updates={num_updates} onClick={ (items) => this.applyUpdates(UPDATES.SELECTED, items) }/>;
+ applySelectAll = <SelectedAllButton />;
if (num_security_updates > 0 && num_updates > num_security_updates) {
applySecurity = (
@@ -1455,6 +1638,8 @@ class OsUpdates extends React.Component {
<CardsPage handleRefresh={this.handleRefresh}
applySecurity={applySecurity}
applyAll={applyAll}
+ applySelected={applySelected}
+ applySelectAll={applySelectAll}
applyKpatches={applyKpatches}
highestSeverity={highest_severity}
onValueChanged={this.onValueChanged}
@@ -1645,5 +1830,9 @@ document.addEventListener("DOMContentLoaded", () => {
document.title = cockpit.gettext(document.title);
init();
const root = createRoot(document.getElementById('app'));
- root.render(<OsUpdates />);
+ root.render(
+ <SelectedStore>
+ <OsUpdates />
+ </SelectedStore>
+ );
});
diff --git a/pkg/packagekit/updates.scss b/pkg/packagekit/updates.scss
index 00718eff2..12bc5de2b 100644
--- a/pkg/packagekit/updates.scss
+++ b/pkg/packagekit/updates.scss
@@ -72,7 +72,7 @@
}
&, p {
- max-inline-size: 60vw;
+ max-inline-size: 54vw;
margin-block-end: 0; // counter-act <Markdown>
overflow: hidden;
text-overflow: ellipsis;
@@ -273,3 +273,7 @@ table.header-buttons {
.ct-info-circle {
color: var(--pf-v5-global--info-color--100);
}
+
+td.select-update {
+ min-width: 8vw;
+}
--
2.48.1