From b03b7eaab34890d197d8e3f536f15390dd123064 Mon Sep 17 00:00:00 2001 From: Miika Alikirri Date: Mon, 28 Apr 2025 20:50:17 +0530 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, 220 insertions(+), 25 deletions(-) diff --git a/pkg/packagekit/updates.jsx b/pkg/packagekit/updates.jsx index 40fca0a..abc8c70 100644 --- a/pkg/packagekit/updates.jsx +++ b/pkg/packagekit/updates.jsx @@ -81,6 +81,7 @@ import { read_os_release } from "os-release.js"; import callTracerScript from './callTracer.py'; import "./updates.scss"; +import { Checkbox } from '@patternfly/react-core'; import { Truncate } from '@patternfly/react-core/dist/esm/components/Truncate/index.js'; import { Severity } from '_internal/packagemanager-abstract'; @@ -95,6 +96,7 @@ const UPDATES = { ALL: 0, SECURITY: 1, KPATCHES: 2, + SELECTED: 3, }; function init() { @@ -119,6 +121,195 @@ 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]); +} + function deduplicate(list) { return [...new Set(list)].sort(); } @@ -381,6 +572,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, @@ -430,6 +622,7 @@ const UpdatesList = ({ updates }) => { { title: _("Version"), props: { width: 15 } }, { title: _("Severity"), props: { width: 15 } }, { title: _("Details"), props: { width: 30 } }, + { title: _("Select update") }, ]} rows={combined_updates.map(update => updateItem(remarkable, update, packageNames[update.id].sort((a, b) => a.name > b.name), update.id))} /> ); @@ -913,25 +1106,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: @@ -1315,13 +1495,19 @@ class OsUpdates extends React.Component { }); } - applyUpdates(type) { + /** + * @param {SelecetedState=} selected + */ + applyUpdates(type, selected) { let updates = [...this.state.updates]; if (type === UPDATES.SECURITY) updates = updates.filter(update => update.severity === Severity.CRITICAL); if (type === UPDATES.KPATCHES) { updates = updates.filter(update => isKpatchPackage(update.name)); } + if (type === UPDATES.SELECTED && selected) { + ids = calculateSelected(ids, selected); + } PK.transaction() .then(transactionPath => { @@ -1347,6 +1533,8 @@ class OsUpdates extends React.Component { let applySecurity; let applyKpatches; let applyAll; + let applySelected; + let 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 @@ -1399,12 +1587,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 = ( @@ -1445,6 +1629,8 @@ class OsUpdates extends React.Component { { } const root = createRoot(document.getElementById('app')); - root.render(); + root.render( + + + + ); }); diff --git a/pkg/packagekit/updates.scss b/pkg/packagekit/updates.scss index 174d6fd..82979ff 100644 --- a/pkg/packagekit/updates.scss +++ b/pkg/packagekit/updates.scss @@ -71,7 +71,7 @@ } &, p { - max-inline-size: 60vw; + max-inline-size: 54vw; margin-block-end: 0; // counter-act overflow: hidden; text-overflow: ellipsis; @@ -280,3 +280,7 @@ table.header-buttons { .ct-info-circle { color: var(--pf-t--global--icon--color--status--info--default); } + +td.select-update { + min-width: 8vw; +}