From 291aba8127cb3e44e82b164e75d1063f3ae7fe9c Mon Sep 17 00:00:00 2001 From: Miika Alikirri 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.} 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 ; +}; + +/** + * @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 ( + + ); +} + +const SelectedAllButton = (props) => { + const { state, dispatch } = useSelected(); + + const dispatchSelect = () => { + if (state.allSelected) { + dispatch({type: "NONE"}); + } else { + dispatch({type: "ALL"}); + } + } + + return ( + + ); +} + +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 ( + 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 ( + + + + + {_("Danger alert:")} + {_("Web Console will restart")} + + + + + + + + + ); +} + +/** + * @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: {info.version}, props: { className: "version" } }, { title: {type}, props: { className: "type" } }, { title: descriptionFirstLine, props: { className: "changelog" } }, + { title: , 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: (
- {this.props.cockpitUpdate && - - - - - {_("Danger alert:")} - {_("Web Console will restart")} - - - - - - - - } + {this.props.applyKpatches} {this.props.applySecurity} {this.props.applyAll} + {this.props.applySelected} + {this.props.applySelectAll}
), containsList: true, body: @@ -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 = ( - ); + applySelected = this.applyUpdates(UPDATES.SELECTED, items) }/>; + applySelectAll = ; if (num_security_updates > 0 && num_updates > num_security_updates) { applySecurity = ( @@ -1455,6 +1638,8 @@ class OsUpdates extends React.Component { { document.title = cockpit.gettext(document.title); init(); const root = createRoot(document.getElementById('app')); - root.render(); + root.render( + + + + ); }); 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 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