Line data Source code
1 : /*
2 : * Copyright (C) 2017 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 : import '../lib/patternfly/patternfly-6-cockpit.scss';
6 : import 'polyfills'; // once per application
7 : import 'cockpit-dark-theme'; // once per page
8 :
9 19 : import cockpit from "cockpit";
10 19 : import React from "react";
11 19 : import { createRoot } from 'react-dom/client';
12 :
13 : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
14 : import { Badge } from "@patternfly/react-core/dist/esm/components/Badge/index.js";
15 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
16 : import { CodeBlock, CodeBlockCode } from "@patternfly/react-core/dist/esm/components/CodeBlock/index.js";
17 : import { Gallery } from "@patternfly/react-core/dist/esm/layouts/Gallery/index.js";
18 : import {
19 : Modal, ModalBody, ModalFooter, ModalHeader
20 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
21 : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
22 : import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
23 : import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
24 : import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
25 : import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
26 : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
27 : import { Grid, GridItem } from "@patternfly/react-core/dist/esm/layouts/Grid/index.js";
28 : import { LabelGroup } from "@patternfly/react-core/dist/esm/components/Label/index.js";
29 : import { Page, PageSection, } from "@patternfly/react-core/dist/esm/components/Page/index.js";
30 : import { Progress, ProgressSize } from "@patternfly/react-core/dist/esm/components/Progress/index.js";
31 : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
32 : import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
33 : import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
34 : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
35 :
36 : import {
37 : BugIcon,
38 : CheckIcon,
39 : EnhancementIcon,
40 : ExclamationCircleIcon,
41 : ExclamationTriangleIcon,
42 : RebootingIcon,
43 : RedoIcon,
44 : ProcessAutomationIcon,
45 : SecurityIcon,
46 : } from "@patternfly/react-icons";
47 : import { TableText } from "@patternfly/react-table";
48 : import { Remarkable } from "remarkable";
49 :
50 : import { AutoUpdates, getBackend } from "./autoupdates.jsx";
51 : import { KpatchSettings, KpatchStatus } from "./kpatch";
52 : import { History, PackageList } from "./history";
53 : import { page_status } from "notifications";
54 : import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
55 : import { ListingTable } from 'cockpit-components-table.jsx';
56 : import { ModalError } from 'cockpit-components-inline-notification.jsx';
57 : import { ShutdownModal } from 'cockpit-components-shutdown.jsx';
58 : import { WithDialogs } from "dialogs.jsx";
59 :
60 : import { superuser } from 'superuser';
61 : import * as PK from "packagekit.js";
62 : import * as python from "python.js";
63 : import * as timeformat from "timeformat";
64 :
65 : import { debug, watchRedHatSubscription } from './utils';
66 : import { read_os_release } from "os-release.js";
67 : import callTracerScript from './callTracer.py';
68 :
69 : import "./updates.scss";
70 : import { Truncate } from '@patternfly/react-core/dist/esm/components/Truncate/index.js';
71 : import { Severity } from '_internal/packagemanager-abstract';
72 : import { getPackageManager } from 'packagemanager';
73 : import { Icon } from '@patternfly/react-core/dist/esm/components/Icon/index.js';
74 :
75 19 : const _ = cockpit.gettext;
76 :
77 : // "available" heading is built dynamically
78 19 : const STATE_HEADINGS = {};
79 19 : const PK_STATUS_STRINGS = {};
80 19 : const PK_STATUS_LOG_STRINGS = {};
81 :
82 19 : const UPDATES = {
83 19 : ALL: 0,
84 19 : SECURITY: 1,
85 19 : KPATCHES: 2,
86 19 : };
87 :
88 19 : function init() {
89 19 : STATE_HEADINGS.loading = _("Loading available updates, please wait...");
90 19 : STATE_HEADINGS.locked = _("Some other program is currently using the package manager, please wait...");
91 19 : STATE_HEADINGS.refreshing = _("Refreshing package information");
92 19 : STATE_HEADINGS.uptodate = _("System is up to date");
93 19 : STATE_HEADINGS.applying = _("Applying updates");
94 19 : STATE_HEADINGS.updateError = _("Applying updates failed");
95 19 : STATE_HEADINGS.loadError = _("Loading available updates failed");
96 :
97 19 : PK_STATUS_STRINGS[PK.Enum.STATUS_DOWNLOAD] = _("Downloading");
98 19 : PK_STATUS_STRINGS[PK.Enum.STATUS_INSTALL] = _("Installing");
99 19 : PK_STATUS_STRINGS[PK.Enum.STATUS_UPDATE] = _("Updating");
100 19 : PK_STATUS_STRINGS[PK.Enum.STATUS_CLEANUP] = _("Setting up");
101 19 : PK_STATUS_STRINGS[PK.Enum.STATUS_SIGCHECK] = _("Verifying");
102 :
103 19 : PK_STATUS_LOG_STRINGS[PK.Enum.STATUS_DOWNLOAD] = _("Downloaded");
104 19 : PK_STATUS_LOG_STRINGS[PK.Enum.STATUS_INSTALL] = _("Installed");
105 19 : PK_STATUS_LOG_STRINGS[PK.Enum.STATUS_UPDATE] = _("Updated");
106 19 : PK_STATUS_LOG_STRINGS[PK.Enum.STATUS_CLEANUP] = _("Set up");
107 19 : PK_STATUS_LOG_STRINGS[PK.Enum.STATUS_SIGCHECK] = _("Verified");
108 19 : }
109 :
110 15 : function deduplicate(list) {
111 15 : return [...new Set(list)].sort();
112 15 : }
113 :
114 : // Insert comma strings in between elements of the list. Unlike list.join(",")
115 : // this does not stringify the elements, which we need to keep as JSX objects.
116 3 : function insertCommas(list) {
117 3 : if (list.length <= 1)
118 3 : return list;
119 1 : return list.reduce((prev, cur) => [prev, ", ", cur]);
120 3 : }
121 :
122 : // Fedora changelogs are a wild mix of enumerations or not, headings, etc.
123 : // Remove that formatting to avoid an untidy updates overview list
124 15 : function cleanupChangelogLine(text) {
125 15 : if (!text)
126 12 : return text;
127 :
128 : // enumerations
129 5 : text = text.replace(/^[-* ]*/, "");
130 :
131 : // headings
132 5 : text = text.replace(/^=+\s+/, "").replace(/=+\s*$/, "");
133 :
134 5 : return text.trim();
135 15 : }
136 :
137 : // Replace cockpit-wsinstance-https@[long_id] with a shorter string
138 15 : function shortenCockpitWsInstance(list) {
139 1 : return list.map(item => item.startsWith('cockpit-wsinstance-https') ? 'cockpit-wsinstance-https@.' : item);
140 15 : }
141 :
142 18 : function count_security_updates(updates) {
143 18 : let num_security = 0;
144 18 : for (const u of updates)
145 16 : if (u.severity === Severity.CRITICAL)
146 5 : ++num_security;
147 18 : return num_security;
148 18 : }
149 :
150 15 : function isKpatchPackage(name) {
151 15 : return name.startsWith("kpatch-patch");
152 15 : }
153 :
154 15 : function count_kpatch_updates(updates) {
155 15 : let num_kpatches = 0;
156 15 : for (const u of updates)
157 15 : if (isKpatchPackage(u.name))
158 2 : ++num_kpatches;
159 15 : return num_kpatches;
160 15 : }
161 :
162 15 : function find_highest_severity(updates) {
163 15 : let max = Severity.LOW;
164 15 : for (const u of updates)
165 15 : if (u.severity > max)
166 15 : max = u.severity;
167 15 : return max;
168 15 : }
169 :
170 : /**
171 : * Get appropriate icon for an update severity
172 : *
173 : * info: An Severity level
174 : * secSeverity: If given, further classification of the severity of Severity.CRITICAL from the vendor_urls;
175 : * e. g. "critical", see https://access.redhat.com/security/updates/classification
176 : * Returns: Icon JSX object
177 : *
178 : */
179 15 : function getSeverityIcon(info, secSeverity) {
180 15 : let classes = "severity-icon";
181 15 : if (secSeverity)
182 2 : classes += " severity-" + secSeverity;
183 15 : if (info == Severity.CRITICAL)
184 3 : return <Icon isInline status="danger"><SecurityIcon aria-label={secSeverity || _("security")} className={classes} /></Icon>;
185 14 : else if (info >= Severity.IMPORTANT)
186 3 : return <Icon isInline className='pf-m-important'><BugIcon className={classes} aria-label={_("bug fix")} /></Icon>;
187 : else
188 3 : return <Icon isInline status="custom"><EnhancementIcon className={classes} aria-label={_("enhancement")} /></Icon>;
189 15 : }
190 :
191 15 : function getPageStatusSeverityIcon(severity) {
192 15 : if (severity == Severity.CRITICAL)
193 3 : return "security";
194 14 : else if (severity >= Severity.IMPORTANT)
195 2 : return "bug";
196 : else
197 2 : return "enhancement";
198 15 : }
199 :
200 15 : function getSeverityURL(urls) {
201 15 : if (!urls)
202 12 : return null;
203 :
204 : // in ascending severity
205 5 : const knownLevels = ["low", "moderate", "important", "critical"];
206 5 : let highestIndex = -1;
207 5 : let highestURL = null;
208 :
209 : // search URLs for highest valid severity; by all means we expect an update to have at most one, but for paranoia..
210 0 : urls.forEach(value => {
211 0 : if (value.startsWith("https://access.redhat.com/security/updates/classification/#")) {
212 0 : const i = knownLevels.indexOf(value.slice(value.indexOf("#") + 1));
213 0 : if (i > highestIndex) {
214 0 : highestIndex = i;
215 0 : highestURL = value;
216 0 : }
217 0 : }
218 0 : });
219 5 : return highestURL;
220 15 : }
221 :
222 : // Overrides the link_open function to apply our required HTML attributes
223 15 : function customRemarkable() {
224 15 : const remarkable = new Remarkable();
225 :
226 15 : const orig_link_open = remarkable.renderer.rules.link_open;
227 1 : remarkable.renderer.rules.link_open = function() {
228 1 : let result = orig_link_open.apply(null, arguments);
229 :
230 1 : const parser = new DOMParser();
231 1 : const htmlDocument = parser.parseFromString(result, "text/html");
232 1 : const links = htmlDocument.getElementsByTagName("a");
233 1 : if (links.length === 1) {
234 1 : const href = links[0].getAttribute("href");
235 1 : result = `<a rel="noopener noreferrer" target="_blank" href="${href}">`;
236 1 : }
237 1 : return result;
238 1 : };
239 15 : return remarkable;
240 15 : }
241 :
242 15 : function updateItem(remarkable, info, pkgNames, key) {
243 15 : let bugs = null;
244 3 : if (info.bug_urls && info.bug_urls.length) {
245 : // we assume a bug URL ends with a number; if not, show the complete URL
246 1 : bugs = insertCommas(info.bug_urls.map(url => (
247 1 : <a key={url} rel="noopener noreferrer" target="_blank" href={url}>
248 0 : {url.match(/[0-9]+$/) || url}
249 1 : </a>)
250 3 : ));
251 3 : }
252 :
253 15 : let cves = null;
254 4 : if (info.cve_urls && info.cve_urls.length) {
255 2 : cves = insertCommas(info.cve_urls.map(url => (
256 2 : <a key={url} href={url} rel="noopener noreferrer" target="_blank">
257 2 : {url.match(/[^/=]+$/)}
258 2 : </a>)
259 4 : ));
260 4 : }
261 :
262 15 : let errata = null;
263 5 : if (info.vendor_urls) {
264 0 : errata = insertCommas(info.vendor_urls.filter(url => url.indexOf("/errata/") > 0).map(url => (
265 0 : <a key={url} href={url} rel="noopener noreferrer" target="_blank">
266 0 : {url.match(/[^/=]+$/)}
267 0 : </a>)
268 5 : ));
269 5 : if (!errata.length)
270 5 : errata = null; // simpler testing below
271 5 : }
272 :
273 15 : let secSeverityURL = getSeverityURL(info.vendor_urls);
274 2 : const secSeverity = secSeverityURL ? secSeverityURL.slice(secSeverityURL.indexOf("#") + 1) : null;
275 15 : const icon = getSeverityIcon(info.severity, secSeverity);
276 15 : let type;
277 4 : if (info.severity === Severity.CRITICAL) {
278 4 : if (secSeverityURL)
279 2 : secSeverityURL = <a rel="noopener noreferrer" target="_blank" href={secSeverityURL}>{secSeverity}</a>;
280 4 : type = (
281 4 : <Tooltip id="tip-severity" content={ secSeverity || _("security") }>
282 4 : <span>
283 4 : {icon}
284 3 : { (info.cve_urls && info.cve_urls.length > 0) ? info.cve_urls.length : "" }
285 4 : </span>
286 4 : </Tooltip>
287 : );
288 3 : } else {
289 3 : const tip = (info.severity >= Severity.IMPORTANT) ? _("bug fix") : _("enhancement");
290 14 : type = (
291 14 : <Tooltip id="tip-severity" content={tip}>
292 14 : <span>
293 14 : {icon}
294 3 : { bugs ? info.bug_urls.length : "" }
295 14 : </span>
296 14 : </Tooltip>
297 : );
298 14 : }
299 :
300 15 : const pkgList = pkgNames.map((n, index) => (
301 15 : <Tooltip key={n.name + n.arch} id="tip-summary" content={n.summary + " (" + n.arch + ")"}>
302 4 : <span>{n.name + (index !== (pkgNames.length - 1) ? ", " : "")}</span>
303 15 : </Tooltip>)
304 15 : );
305 15 : const pkgs = pkgList;
306 15 : const pkgsTruncated = pkgList.slice(0, 4);
307 :
308 15 : if (pkgList.length > 4)
309 3 : pkgsTruncated.push(<span key="more">…</span>);
310 :
311 15 : if (pkgNames.some(pkg => isKpatchPackage(pkg.name)))
312 2 : pkgsTruncated.push(
313 2 : <LabelGroup key={`${key}-kpatches-labelgroup`} className="kpatches-labelgroup">
314 2 : {" "}<Badge color="blue">{_("patches")}</Badge>
315 2 : </LabelGroup>
316 2 : );
317 :
318 12 : let descriptionFirstLine = (info.description || "").trim();
319 15 : if (descriptionFirstLine.indexOf("\n") >= 0)
320 3 : descriptionFirstLine = descriptionFirstLine.slice(0, descriptionFirstLine.indexOf("\n"));
321 15 : descriptionFirstLine = cleanupChangelogLine(descriptionFirstLine);
322 15 : let description;
323 5 : if (info.markdown) {
324 5 : descriptionFirstLine = <span dangerouslySetInnerHTML={{ __html: remarkable.render(descriptionFirstLine) }} />;
325 5 : description = <div dangerouslySetInnerHTML={{ __html: remarkable.render(info.description) }} />;
326 2 : } else {
327 12 : description = <div className="changelog">{info.description}</div>;
328 12 : }
329 :
330 15 : const expandedContent = (
331 15 : <Flex justifyContent={{ default: 'justifyContentSpaceBetween' }}>
332 15 : <DescriptionList>
333 15 : <DescriptionListGroup>
334 15 : <DescriptionListTerm>{_("Packages")}</DescriptionListTerm>
335 15 : <DescriptionListDescription>{pkgs}</DescriptionListDescription>
336 15 : </DescriptionListGroup>
337 15 : { cves
338 4 : ? <DescriptionListGroup>
339 4 : <DescriptionListTerm>{_("CVE")}</DescriptionListTerm>
340 4 : <DescriptionListDescription>{cves}</DescriptionListDescription>
341 4 : </DescriptionListGroup>
342 15 : : null }
343 15 : { secSeverityURL
344 2 : ? <DescriptionListGroup>
345 2 : <DescriptionListTerm>{_("Severity")}</DescriptionListTerm>
346 2 : <DescriptionListDescription className="severity">{secSeverityURL}</DescriptionListDescription>
347 2 : </DescriptionListGroup>
348 15 : : null }
349 15 : { errata
350 2 : ? <DescriptionListGroup>
351 2 : <DescriptionListTerm>{_("Errata")}</DescriptionListTerm>
352 2 : <DescriptionListDescription>{errata}</DescriptionListDescription>
353 2 : </DescriptionListGroup>
354 15 : : null }
355 15 : { bugs
356 3 : ? <DescriptionListGroup>
357 3 : <DescriptionListTerm>{_("Bugs")}</DescriptionListTerm>
358 3 : <DescriptionListDescription>{bugs}</DescriptionListDescription>
359 3 : </DescriptionListGroup>
360 15 : : null }
361 15 : </DescriptionList>
362 15 : <Content>{description}</Content>
363 15 : </Flex>
364 : );
365 :
366 15 : return {
367 15 : columns: [
368 15 : { title: pkgsTruncated },
369 15 : { title: <TableText wrapModifier="truncate">{info.version}</TableText>, props: { className: "version" } },
370 15 : { title: <TableText wrapModifier="nowrap">{type}</TableText>, props: { className: "type" } },
371 15 : { title: descriptionFirstLine, props: { className: "changelog" } },
372 15 : ],
373 15 : props: {
374 15 : key,
375 3 : className: info.severity === Severity.CRITICAL ? ["error"] : [],
376 15 : },
377 15 : hasPadding: true,
378 15 : expandedContent,
379 15 : };
380 15 : }
381 :
382 15 : const UpdatesList = ({ updates }) => {
383 15 : const remarkable = customRemarkable();
384 15 : const combined_updates = [];
385 :
386 : // PackageKit doesn"t expose source package names, so group packages with the same version and changelog
387 : // create a reverse version+changes → [id] map on iteration
388 15 : const sameUpdate = {};
389 15 : const packageNames = {};
390 15 : for (const u of updates) {
391 : // did we already see the same version and description? then merge
392 15 : const hash = u.version + u.description;
393 15 : const seenId = sameUpdate[hash];
394 4 : if (seenId) {
395 4 : packageNames[seenId].push({ name: u.name, arch: u.arch, summary: u.summary });
396 4 : } else {
397 : // this is a new update
398 15 : sameUpdate[hash] = u.id;
399 15 : packageNames[u.id] = [{ name: u.name, arch: u.arch, summary: u.summary }];
400 15 : combined_updates.push(u);
401 15 : }
402 15 : }
403 :
404 : // sort security first
405 6 : combined_updates.sort((a, b) => {
406 2 : if (a.severity === Severity.CRITICAL && b.severity !== Severity.CRITICAL)
407 1 : return -1;
408 5 : if (a.severity !== Severity.CRITICAL && b.severity === Severity.CRITICAL)
409 1 : return 1;
410 6 : return a.name.localeCompare(b.name);
411 6 : });
412 :
413 15 : return (
414 15 : <ListingTable aria-label={_("Available updates")}
415 15 : gridBreakPoint='grid-lg'
416 15 : columns={[
417 15 : { title: _("Name"), props: { width: 40 } },
418 15 : { title: _("Version"), props: { width: 15 } },
419 15 : { title: _("Severity"), props: { width: 15 } },
420 15 : { title: _("Details"), props: { width: 30 } },
421 15 : ]}
422 3 : rows={combined_updates.map(update => updateItem(remarkable, update, packageNames[update.id].sort((a, b) => a.name > b.name), update.id))} />
423 : );
424 15 : };
425 :
426 19 : class RestartServices extends React.Component {
427 2 : constructor(props) {
428 2 : super(props);
429 2 : this.state = {
430 2 : dialogError: undefined,
431 2 : dialogErrorDetail: undefined,
432 2 : restartInProgress: false,
433 2 : };
434 :
435 2 : this.restart = this.restart.bind(this);
436 2 : }
437 :
438 2 : restart() {
439 : // make sure cockpit package is the last to restart
440 0 : const daemons = this.props.restartPackages.daemons.sort((a, b) => {
441 0 : if (a.includes("cockpit") && b.includes("cockpit"))
442 0 : return 0;
443 0 : if (a.includes("cockpit"))
444 0 : return 1;
445 0 : return a.localeCompare(b);
446 0 : });
447 2 : const restarts = daemons.map(service => cockpit.spawn(["systemctl", "restart", service], { superuser: "require", err: "message" }));
448 2 : this.setState({ restartInProgress: true, dialogError: undefined, dialogErrorDetail: undefined });
449 2 : Promise.all(restarts)
450 1 : .then(() => {
451 1 : this.props.onValueChanged({ restartPackages: { reboot: this.props.restartPackages.reboot, daemons: [], manual: this.props.restartPackages.manual } });
452 1 : if (this.props.state === "updateSuccess")
453 1 : this.props.loadUpdates();
454 1 : this.setState({ restartInProgress: false });
455 1 : this.props.close();
456 1 : })
457 1 : .catch(ex => {
458 1 : this.setState({ dialogError: _("Failed to restart service"), dialogErrorDetail: ex.message });
459 : // see what services remain
460 1 : this.props.checkNeedsRestart();
461 1 : });
462 2 : }
463 :
464 2 : render() {
465 2 : let body;
466 1 : if (this.props.checkRestartRunning) {
467 1 : body = (
468 1 : <Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
469 1 : <Spinner size="sm" />
470 1 : <p>{_("Reloading the state of remaining services")}</p>
471 1 : </Flex>
472 : );
473 1 : } else if (this.props.restartPackages.daemons.length > 0) {
474 2 : body = (<>
475 2 : {cockpit.ngettext("The following service will be restarted:", "The following services will be restarted:", this.props.restartPackages.daemons.length)}
476 2 : <TwoColumnContent list={this.props.restartPackages.daemons} flexClassName="restart-services-modal-body" />
477 2 : </>);
478 2 : }
479 :
480 2 : return (
481 2 : <Modal id="restart-services-modal" isOpen
482 2 : position="top"
483 2 : variant="medium"
484 2 : onClose={this.props.close}>
485 2 : <ModalHeader title={_("Restart services")} />
486 2 : <ModalBody>
487 2 : <Stack hasGutter>
488 2 : {this.state.dialogError && <ModalError dialogError={this.state.dialogError} dialogErrorDetail={this.state.dialogErrorDetail} />}
489 2 : <StackItem>{body}</StackItem>
490 2 : </Stack>
491 2 : </ModalBody>
492 2 : <ModalFooter>
493 2 : {this.props.restartPackages.daemons.includes("cockpit") &&
494 1 : <Alert variant="warning"
495 1 : title={_("Web Console will restart")}
496 1 : isInline>
497 1 : <p>
498 1 : {_("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.")}
499 1 : </p>
500 1 : </Alert>}
501 2 : <Button variant='primary'
502 2 : isDisabled={ this.state.restartInProgress }
503 2 : onClick={ this.restart }>
504 2 : {_("Restart services")}
505 2 : </Button>
506 2 : <Button variant='link' className='btn-cancel' onClick={ this.props.close }>
507 2 : {_("Cancel")}
508 2 : </Button>
509 2 : </ModalFooter>
510 2 : </Modal>
511 : );
512 2 : }
513 19 : }
514 :
515 11 : const formatPackageId = packageId => {
516 11 : const pfields = packageId.split(";");
517 11 : return pfields[0] + " " + pfields[1] + " (" + pfields[2] + ")";
518 11 : };
519 :
520 : // actions is a chronological list of { status, packageId } events that happen during applying updates
521 : // status: see PK_STATUS_* at https://github.com/PackageKit/PackageKit/blob/main/lib/packagekit-glib2/pk-enum.h
522 12 : const ApplyUpdates = ({ transactionProps, actions, onCancel, rebootAfter, setRebootAfter }) => {
523 12 : const remain = transactionProps.RemainingTime
524 2 : ? timeformat.distanceToNow(new Date().valueOf() + transactionProps.RemainingTime * 1000)
525 12 : : null;
526 :
527 12 : let percentage = transactionProps.Percentage || 0;
528 : // PackageKit sets this to 101 initially
529 12 : if (percentage > 100)
530 12 : percentage = 0;
531 :
532 : // scroll update log to the bottom, if it already is (almost) at the bottom
533 12 : const log = document.getElementById("update-log");
534 11 : if (log) {
535 11 : if (log.scrollHeight - log.clientHeight <= log.scrollTop + 2)
536 11 : log.scrollTop = log.scrollHeight;
537 11 : }
538 :
539 12 : const cancelButton = transactionProps.AllowCancel
540 12 : ? <Button variant="secondary" onClick={onCancel} size="sm">{_("Cancel")}</Button>
541 12 : : null;
542 :
543 12 : if (actions.length === 0 && percentage === 0) {
544 12 : return <EmptyStatePanel title={ _("Initializing...") }
545 12 : headingLevel="h5"
546 12 : secondary={cancelButton}
547 12 : loading
548 12 : />;
549 12 : }
550 :
551 11 : const lastAction = actions[actions.length - 1];
552 : // when resuming an upgrade, we did not get any Package signal yet; fall back to LastPackage
553 10 : const curPackage = formatPackageId(lastAction?.packageId || transactionProps.LastPackage || "");
554 12 : return (
555 12 : <div className="progress-main-view">
556 12 : <Grid hasGutter>
557 12 : <GridItem span={12}>
558 12 : <div className="progress-description pf-v6-u-display-flex">
559 12 : <Spinner size="md" isInline />
560 10 : <strong>{PK_STATUS_STRINGS[lastAction?.status] || PK_STATUS_STRINGS[PK.Enum.STATUS_UPDATE]}</strong>
561 :
562 12 : <Truncate content={curPackage} />
563 12 : </div>
564 12 : <Progress title={remain}
565 2 : aria-label={remain ? _("Time remaining: ") : _("Update progress")}
566 12 : value={percentage}
567 12 : size={ProgressSize.sm}
568 12 : className="pf-v6-u-mb-xs" />
569 12 : </GridItem>
570 :
571 12 : <GridItem span={3}>{cancelButton}</GridItem>
572 :
573 12 : <GridItem span={12}>
574 12 : <Switch id="reboot-after" isChecked={rebootAfter}
575 12 : label={ _("Reboot after completion") }
576 12 : onChange={setRebootAfter} />
577 12 : </GridItem>
578 :
579 12 : <GridItem span={12} className="update-log">
580 3 : <ExpandableSection toggleText={_("View update log")} onToggle={() => {
581 : // always scroll down on expansion
582 3 : const log = document.getElementById("update-log");
583 3 : log.scrollTop = log.scrollHeight;
584 3 : }}>
585 12 : <div id="update-log" className="update-log-content">
586 12 : <table>
587 12 : <tbody>
588 9 : { actions.slice(0, -1).map((action, i) => (
589 9 : <tr key={action.packageId + i}>
590 1 : <th>{PK_STATUS_LOG_STRINGS[action.status] || PK_STATUS_LOG_STRINGS[PK.Enum.STATUS_UPDATE]}</th>
591 9 : <td>{formatPackageId(action.packageId)}</td>
592 9 : </tr>)) }
593 12 : </tbody>
594 12 : </table>
595 12 : </div>
596 12 : </ExpandableSection>
597 12 : </GridItem>
598 12 : </Grid>
599 12 : </div>
600 : );
601 12 : };
602 :
603 2 : const TwoColumnContent = ({ list, flexClassName }) => {
604 2 : const half = Math.round(list.length / 2);
605 2 : const col1 = list.slice(0, half);
606 2 : const col2 = list.slice(half);
607 2 : return (
608 2 : <Flex className={flexClassName}>
609 2 : <FlexItem flex={{ default: 'flex_1' }}>
610 2 : <Content component="ul">
611 2 : {col1.map(item => (<Content component="li" key={item}>{item}</Content>))}
612 2 : </Content>
613 2 : </FlexItem>
614 1 : {col2.length > 0 && <FlexItem flex={{ default: 'flex_1' }}>
615 1 : <Content component="ul">
616 0 : {col2.map(item => (<Content component="li" key={item}>{item}</Content>))}
617 1 : </Content>
618 1 : </FlexItem>}
619 2 : </Flex>
620 : );
621 2 : };
622 :
623 2 : const TwoColumnTitle = ({ icon, str }) => {
624 2 : return (<>
625 2 : {icon}
626 2 : <span className="update-success-table-title">
627 2 : {str}
628 2 : </span>
629 2 : </>);
630 2 : };
631 :
632 9 : const UpdateSuccess = ({ onIgnore, openServiceRestartDialog, openRebootDialog, restart, manual, reboot, checkRestartAvailable, history }) => {
633 2 : if (!checkRestartAvailable) {
634 : /* tracer is not available any more in RHEL 10; as a special case, if only kpatch and kernel were
635 : * updated, don't reboot (as that's their whole raison d'être) */
636 1 : const pkgs = Object.keys(history[0]?.packages ?? {});
637 1 : const only_kpatch = pkgs.filter(p => p.startsWith("kpatch")).length > 0 &&
638 0 : pkgs.filter(p => !p.startsWith("kernel") && !p.startsWith("kpatch")).length == 0;
639 :
640 1 : const paragraph = only_kpatch ? null : _("Updated packages may require a reboot to take effect.");
641 2 : const actions = only_kpatch
642 1 : ? <Button id="ignore" variant="primary" onClick={onIgnore}>{_("Continue")}</Button>
643 2 : : <>
644 2 : <Button id="reboot-system" variant="primary" onClick={openRebootDialog}>{_("Reboot system...")}</Button>
645 2 : <Button id="ignore" variant="link" onClick={onIgnore}>{_("Ignore")}</Button>
646 2 : </>;
647 :
648 2 : return (<>
649 2 : <EmptyStatePanel icon={RebootingIcon}
650 2 : title={ _("Update was successful") }
651 2 : headingLevel="h5"
652 2 : paragraph={paragraph}
653 2 : secondary={actions} />
654 2 : { history[0]?.packages &&
655 2 : <div className="flow-list-blank-slate">
656 2 : <ExpandableSection toggleText={_("Package information")}>
657 2 : <PackageList packages={history[0].packages} />
658 2 : </ExpandableSection>
659 2 : </div>
660 : }
661 2 : </>);
662 2 : }
663 :
664 8 : const entries = [];
665 1 : if (reboot.length > 0) {
666 1 : entries.push({
667 1 : columns: [
668 1 : {
669 1 : title: <TwoColumnTitle icon={<RebootingIcon />}
670 1 : str={cockpit.format(cockpit.ngettext("$0 package needs a system reboot",
671 1 : "$0 packages need a system reboot",
672 1 : reboot.length),
673 1 : reboot.length)} />
674 1 : },
675 1 : ],
676 1 : props: { key: "reboot", id: "reboot-row" },
677 1 : hasPadding: true,
678 1 : expandedContent: <TwoColumnContent list={reboot} />,
679 1 : });
680 1 : }
681 :
682 2 : if (restart.length > 0) {
683 2 : entries.push({
684 2 : columns: [
685 2 : {
686 2 : title: <TwoColumnTitle icon={<ProcessAutomationIcon />}
687 2 : str={cockpit.format(cockpit.ngettext("$0 service needs to be restarted",
688 2 : "$0 services need to be restarted",
689 2 : restart.length),
690 2 : restart.length)} />
691 2 : },
692 2 : ],
693 2 : props: { key: "service", id: "service-row" },
694 2 : hasPadding: true,
695 2 : expandedContent: <TwoColumnContent list={restart} />,
696 2 : });
697 2 : }
698 :
699 1 : if (manual.length > 0) {
700 1 : entries.push({
701 1 : columns: [
702 1 : {
703 1 : title: <TwoColumnTitle icon={<ProcessAutomationIcon />}
704 1 : str={_("Some software needs to be restarted manually")} />
705 1 : }
706 1 : ],
707 1 : props: { key: "manual", id: "manual-row" },
708 1 : hasPadding: true,
709 1 : expandedContent: <TwoColumnContent list={manual} />,
710 1 : });
711 1 : }
712 :
713 8 : const showReboot = reboot.length > 0 || manual.length > 0;
714 :
715 9 : return (<>
716 9 : <EmptyStatePanel title={ _("Update was successful") }
717 9 : headingLevel="h5"
718 9 : secondary={
719 9 : <>
720 2 : { entries.length > 0 && <ListingTable aria-label={_("Update Success Table")}
721 2 : columns={[{ title: _("Info") }]}
722 2 : showHeader={false}
723 2 : className="updates-success-table"
724 2 : rows={entries} /> }
725 9 : <div className="update-success-actions">
726 1 : { showReboot && <Button id="reboot-system" variant="primary" onClick={openRebootDialog}>{_("Reboot system...")}</Button> }
727 1 : { restart.length > 0 && <Button id="choose-service" variant={showReboot ? "secondary" : "primary"} onClick={openServiceRestartDialog}>{_("Restart services...")}</Button> }
728 8 : { reboot.length > 0 || restart.length > 0 || manual.length > 0
729 2 : ? <Button id="ignore" variant="link" onClick={onIgnore}>{_("Ignore")}</Button>
730 8 : : <Button id="ignore" variant="primary" onClick={onIgnore}>{_("Continue")}</Button> }
731 9 : </div>
732 9 : </>
733 9 : } />
734 8 : { history[0]?.packages &&
735 8 : <div className="flow-list-blank-slate">
736 8 : <ExpandableSection toggleText={_("Package information")}>
737 8 : <PackageList packages={history[0].packages} />
738 8 : </ExpandableSection>
739 8 : </div>
740 : }
741 9 : </>);
742 9 : };
743 :
744 18 : const UpdatesStatus = ({ updates, highestSeverity, timeSinceRefresh, restartPackages, onValueChanged }) => {
745 18 : const numUpdates = updates.length;
746 18 : const numSecurity = count_security_updates(updates);
747 18 : const numRestartServices = restartPackages.daemons.length;
748 18 : const numManualSoftware = restartPackages.manual.length;
749 18 : const numRebootPackages = restartPackages.reboot.length;
750 18 : let lastChecked;
751 : // PackageKit returns G_MAXUINT if the db was never checked.
752 18 : if (timeSinceRefresh !== null && timeSinceRefresh !== 2 ** 32 - 1)
753 18 : lastChecked = cockpit.format(_("Last checked: $0"), timeformat.distanceToNow(new Date().valueOf() - timeSinceRefresh * 1000));
754 :
755 18 : const notifications = [];
756 16 : if (numUpdates > 0) {
757 4 : if (numUpdates == numSecurity) {
758 4 : const stateStr = cockpit.ngettext("$0 security fix available", "$0 security fixes available", numSecurity);
759 4 : notifications.push({
760 4 : id: "security-updates-available",
761 4 : stateStr: cockpit.format(stateStr, numSecurity),
762 4 : icon: getSeverityIcon(highestSeverity),
763 4 : secondary: <Content id="last-checked" component={ContentVariants.small}>{lastChecked}</Content>
764 4 : });
765 3 : } else {
766 15 : let stateStr = cockpit.ngettext("$0 update available", "$0 updates available", numUpdates);
767 15 : if (numSecurity > 0)
768 4 : stateStr += cockpit.ngettext(", including $1 security fix", ", including $1 security fixes", numSecurity);
769 15 : notifications.push({
770 15 : id: "updates-available",
771 15 : stateStr: cockpit.format(stateStr, numUpdates, numSecurity),
772 15 : icon: getSeverityIcon(highestSeverity),
773 15 : secondary: <Content id="last-checked" component={ContentVariants.small}>{lastChecked}</Content>
774 15 : });
775 15 : }
776 8 : } else if (!numRestartServices && !numRebootPackages && !numManualSoftware) {
777 10 : notifications.push({
778 10 : id: "system-up-to-date",
779 10 : stateStr: STATE_HEADINGS.uptodate,
780 10 : icon: <CheckIcon color="green" />,
781 10 : secondary: <Content id="last-checked" component={ContentVariants.small}>{lastChecked}</Content>
782 10 : });
783 10 : }
784 :
785 3 : if (numRebootPackages > 0) {
786 3 : const stateStr = cockpit.ngettext("$0 package needs a system reboot", "$0 packages need a system reboot", numRebootPackages);
787 3 : notifications.push({
788 3 : id: "packages-need-reboot",
789 3 : stateStr: cockpit.format(stateStr, numRebootPackages),
790 3 : icon: <RebootingIcon />,
791 0 : secondary: <Button variant="danger" onClick={() => onValueChanged("showRebootSystemDialog", true)}>
792 3 : {_("Reboot system...")}
793 3 : </Button>
794 3 : });
795 3 : }
796 :
797 3 : if (numRestartServices > 0) {
798 3 : const stateStr = cockpit.ngettext("$0 service needs to be restarted", "$0 services need to be restarted", numRestartServices);
799 3 : notifications.push({
800 3 : id: "services-need-restart",
801 3 : stateStr: cockpit.format(stateStr, numRestartServices),
802 3 : icon: <ProcessAutomationIcon />,
803 1 : secondary: <Button variant="primary" onClick={() => onValueChanged("showRestartServicesDialog", true)}>
804 3 : {_("Restart services...")}
805 3 : </Button>
806 3 : });
807 3 : }
808 :
809 3 : if (numManualSoftware > 0) {
810 3 : notifications.push({
811 3 : id: "processes-need-restart",
812 3 : stateStr: _("Some software needs to be restarted manually"),
813 3 : icon: <ProcessAutomationIcon />,
814 3 : secondary: <Content component={ContentVariants.small}>{restartPackages.manual.join(", ")}</Content>
815 3 : });
816 3 : }
817 :
818 18 : return (<Stack hasGutter>
819 18 : { notifications.map(notification => (
820 18 : <StackItem key={notification.id}>
821 18 : <Flex flexWrap={{ default: 'nowrap' }} id={notification.id}>
822 18 : <FlexItem>
823 18 : {notification.icon}
824 18 : </FlexItem>
825 18 : <FlexItem>
826 18 : <Stack>
827 18 : <StackItem>
828 18 : <Content component={ContentVariants.p}>{notification.stateStr}</Content>
829 18 : </StackItem>
830 18 : <StackItem>
831 18 : { notification.secondary }
832 18 : </StackItem>
833 18 : </Stack>
834 18 : </FlexItem>
835 18 : </Flex>
836 18 : </StackItem>
837 18 : ))}
838 18 : </Stack>);
839 18 : };
840 :
841 19 : class CardsPage extends React.Component {
842 18 : constructor() {
843 18 : super();
844 18 : this.state = {
845 18 : autoupdates_backend: undefined,
846 18 : };
847 18 : }
848 :
849 18 : componentDidMount() {
850 18 : getBackend(this.props.backend).then(b => { this.setState({ autoupdates_backend: b }) });
851 18 : }
852 :
853 18 : render() {
854 18 : const cardContents = [];
855 18 : let settingsContent = null;
856 18 : const statusContent = <Stack hasGutter>
857 18 : <UpdatesStatus key="updates-status"
858 18 : updates={this.props.updates}
859 18 : onValueChanged={this.props.onValueChanged}
860 18 : restartPackages={this.props.restartPackages}
861 18 : highestSeverity={this.props.highestSeverity}
862 18 : timeSinceRefresh={this.props.timeSinceRefresh} />
863 18 : <KpatchStatus />
864 18 : </Stack>;
865 :
866 18 : if (this.state.autoupdates_backend) {
867 18 : settingsContent = <Stack hasGutter>
868 18 : <AutoUpdates privileged={this.props.privileged} packagekit_backend={this.props.backend} initial_backend={this.state.autoupdates_backend} />
869 18 : {cockpit.info.os_release && cockpit.info.os_release?.ID === "rhel" &&
870 3 : <KpatchSettings privileged={this.props.privileged} />
871 : }
872 18 : </Stack>;
873 18 : }
874 :
875 18 : cardContents.push({
876 18 : id: "status",
877 18 : className: settingsContent !== null ? "ct-card-info" : "",
878 18 : title: _("Status"),
879 18 : actions: (<Tooltip content={_("Check for updates")}>
880 18 : <Button icon={<RedoIcon />} variant="secondary" onClick={this.props.handleRefresh} />
881 18 : </Tooltip>),
882 18 : body: statusContent,
883 18 : });
884 :
885 18 : if (settingsContent !== null) {
886 18 : cardContents.push({
887 18 : id: "settings",
888 18 : className: "ct-card-info",
889 18 : title: _("Settings"),
890 18 : body: settingsContent,
891 18 : });
892 18 : }
893 :
894 16 : if (this.props.state === "available") { // automatic updates are not tracked by PackageKit, hide history when they are enabled
895 16 : cardContents.push({
896 16 : id: "available-updates",
897 16 : title: _("Available updates"),
898 16 : actions: (<div className="pk-updates--header--actions">
899 16 : {this.props.cockpitUpdate &&
900 3 : <Flex flex={{ default: 'inlineFlex' }} className="cockpit-update-warning">
901 3 : <FlexItem>
902 3 : <ExclamationTriangleIcon className="ct-icon-exclamation-triangle cockpit-update-warning-icon" />
903 3 : <strong className="cockpit-update-warning-text">
904 3 : <span className="pf-screen-reader">{_("Danger alert:")}</span>
905 3 : {_("Web Console will restart")}
906 3 : </strong>
907 3 : </FlexItem>
908 3 : <FlexItem>
909 3 : <Popover aria-label="More information popover"
910 3 : 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.")}>
911 3 : <Button variant="link" isInline>{_("More info...")}</Button>
912 3 : </Popover>
913 3 : </FlexItem>
914 3 : </Flex>}
915 16 : {this.props.applyKpatches}
916 16 : {this.props.applySecurity}
917 16 : {this.props.applyAll}
918 16 : </div>),
919 16 : containsList: true,
920 16 : body: <UpdatesList updates={this.props.updates} />
921 16 : });
922 16 : }
923 :
924 8 : if ((!this.state.autoupdates_backend || !this.state.autoupdates_backend.enabled) && this.props.history.length > 0) { // automatic updates are not tracked by PackageKit, hide history when they are enabled
925 8 : cardContents.push({
926 8 : id: "update-history",
927 8 : title: _("Update history"),
928 8 : containsList: true,
929 8 : body: <History packagekit={this.props.history} />
930 8 : });
931 8 : }
932 :
933 18 : return cardContents.map(card => {
934 18 : return (
935 18 : <Card key={card.id} className={card.className} id={card.id}>
936 18 : <CardHeader actions={{ actions: card.actions }}>
937 18 : <CardTitle component="h2">{card.title}</CardTitle>
938 18 : </CardHeader>
939 16 : <CardBody className={card.containsList ? "contains-list" : null}>
940 18 : {card.body}
941 18 : </CardBody>
942 18 : </Card>
943 : );
944 18 : });
945 18 : }
946 19 : }
947 :
948 19 : class OsUpdates extends React.Component {
949 19 : constructor() {
950 19 : super();
951 19 : this.state = {
952 19 : state: "loading",
953 19 : errorMessages: [],
954 19 : updates: [],
955 19 : timeSinceRefresh: null,
956 19 : loadPercent: null,
957 19 : cockpitUpdate: false,
958 19 : haveOsRepo: null,
959 19 : applyTransaction: null,
960 19 : applyTransactionProps: {},
961 19 : applyActions: [],
962 19 : history: [],
963 19 : unregistered: false,
964 19 : privileged: false,
965 19 : autoUpdatesEnabled: undefined,
966 19 : restartPackages: { daemons: [], manual: [], reboot: [] },
967 19 : checkRestartAvailable: false,
968 19 : checkRestartRunning: false,
969 19 : showRestartServicesDialog: false,
970 19 : showRebootSystemDialog: false,
971 19 : backend: "",
972 19 : rebootAfterSuccess: false,
973 19 : packageManager: null,
974 19 : };
975 19 : this.handleLoadError = this.handleLoadError.bind(this);
976 19 : this.handleRefresh = this.handleRefresh.bind(this);
977 19 : this.loadUpdates = this.loadUpdates.bind(this);
978 19 : this.onValueChanged = this.onValueChanged.bind(this);
979 19 : this.checkNeedsRestart = this.checkNeedsRestart.bind(this);
980 19 : }
981 :
982 1 : onValueChanged(key, value) {
983 1 : this.setState({ [key]: value });
984 1 : }
985 :
986 19 : async componentDidMount() {
987 19 : this._mounted = true;
988 19 : this.checkNeedsRestart();
989 :
990 19 : superuser.addEventListener("changed", this.handleSuperUserChange);
991 :
992 : // HACK: force usage of PackageKit backend
993 19 : const packageManager = await getPackageManager(true);
994 19 : const backend = await packageManager.get_backend();
995 19 : this.setState({ packageManager, backend });
996 :
997 : // check if there is an upgrade in progress already; if so, switch to "applying" state right away
998 19 : PK.call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "GetTransactionList", [])
999 19 : .then(([transactions]) => {
1000 19 : if (!this._mounted)
1001 19 : return;
1002 :
1003 1 : const promises = transactions.map(transactionPath => PK.call(
1004 1 : transactionPath, "org.freedesktop.DBus.Properties", "Get", [PK.transactionInterface, "Role"]));
1005 :
1006 19 : Promise.all(promises)
1007 19 : .then(roles => {
1008 : // any transaction with UPDATE_PACKAGES role?
1009 3 : for (let idx = 0; idx < roles.length; ++idx) {
1010 3 : if (roles[idx][0].v === PK.Enum.ROLE_UPDATE_PACKAGES) {
1011 3 : this.watchUpdates(transactions[idx]);
1012 3 : return;
1013 3 : }
1014 3 : }
1015 :
1016 : // no running updates found, proceed to showing available updates
1017 19 : this.initialLoadOrRefresh();
1018 19 : })
1019 0 : .catch(ex => {
1020 0 : console.warn("GetTransactionList: failed to read PackageKit transaction roles:", ex.message);
1021 : // be robust, try to continue with loading updates anyway
1022 0 : this.initialLoadOrRefresh();
1023 0 : });
1024 19 : })
1025 19 : .catch(this.handleLoadError);
1026 19 : }
1027 :
1028 0 : componentWillUnmount() {
1029 0 : this._mounted = false;
1030 0 : superuser.removeEventListener("changed", this.handleSuperUserChange);
1031 0 : }
1032 :
1033 18 : handleSuperUserChange = () => {
1034 18 : this.setState({ privileged: superuser.allowed });
1035 : // get out of error state when switching from unprivileged to privileged
1036 18 : if (superuser.allowed && this.state.state.indexOf("Error") >= 0)
1037 4 : this.loadUpdates();
1038 18 : };
1039 :
1040 19 : checkNeedsRestart() {
1041 19 : this.setState({ checkRestartRunning: true });
1042 19 : return python.spawn(callTracerScript, undefined, { err: "message", superuser: "require" })
1043 15 : .then(output => {
1044 15 : debug("tracer succeeded, output:", output);
1045 15 : const restartPackages = JSON.parse(output);
1046 : // Filter out duplicates
1047 15 : restartPackages.reboot = deduplicate(shortenCockpitWsInstance(restartPackages.reboot));
1048 15 : restartPackages.daemons = deduplicate(shortenCockpitWsInstance(restartPackages.daemons));
1049 15 : restartPackages.manual = deduplicate(shortenCockpitWsInstance(restartPackages.manual));
1050 15 : debug("tracer parsed restartPackages:", JSON.stringify(restartPackages));
1051 15 : this.setState({ checkRestartAvailable: true, checkRestartRunning: false, restartPackages });
1052 15 : })
1053 2 : .catch((exception, data) => {
1054 : // tracer not installed or supported (like on Arch)? then fall back to dnf needs-restarting
1055 2 : if (exception.message?.includes("ModuleNotFoundError") ||
1056 0 : exception.message?.includes("UnsupportedDistribution")) {
1057 0 : debug('tracer not installed:', JSON.stringify(exception), "trying dnf needs-restarting");
1058 0 : return this.checkDnfNeedsRestarting();
1059 0 : }
1060 :
1061 : // log the error except for some common cases: polkit does not allow it
1062 0 : if (exception.problem !== "access-denied" &&
1063 : // or unprivileged session
1064 0 : exception.problem !== "authentication-failed" &&
1065 : // or the session goes away while checking
1066 0 : exception.problem !== "terminated")
1067 0 : console.error(`Tracer failed: "${JSON.stringify(exception)}", data: "${JSON.stringify(data)}"`);
1068 : else
1069 2 : debug('tracer failed for uninteresting reason:', JSON.stringify(exception));
1070 :
1071 : // When tracer fails, act like it's not available (demand reboot after every update)
1072 2 : this.setState({
1073 2 : checkRestartAvailable: false,
1074 2 : checkRestartRunning: false,
1075 2 : restartPackages: { reboot: [], daemons: [], manual: [] },
1076 2 : });
1077 2 : });
1078 19 : }
1079 :
1080 0 : checkDnfNeedsRestarting() {
1081 0 : const restartPackages = { reboot: [], daemons: [], manual: [] };
1082 :
1083 : // needs-restarting has no machine-readable API: https://issues.redhat.com/browse/RHEL-56139
1084 : // dnf5 needs-restarting also has no machine-readable API: https://github.com/rpm-software-management/dnf5/issues/2341
1085 : // --exclude-services was added much later, so check that first
1086 0 : return cockpit.spawn(["dnf", "needs-restarting", "--exclude-services"], { err: "message", superuser: "require" })
1087 0 : .then(outManual => {
1088 0 : debug("dnf needs-restarting --exclude-services succeeded:", outManual);
1089 : // format: "pid : argv", e.g. "1234 : mydaemon 3600"
1090 0 : outManual.trim()
1091 0 : .split("\n")
1092 : // HACK: https://issues.redhat.com/browse/RHEL-84657
1093 0 : .filter(line => line.match(/^\d+ : /))
1094 0 : .forEach(line => !line || restartPackages.manual.push(line));
1095 :
1096 0 : return Promise.allSettled([
1097 0 : cockpit.spawn(["dnf", "needs-restarting", "--services"], { err: "message", superuser: "require" }),
1098 : // we can't get stdout for a failing process, thus needs script
1099 0 : cockpit.script("! dnf needs-restarting --reboothint", undefined, { err: "message", superuser: "require" }),
1100 0 : ])
1101 0 : .then(([serviceResult, rebootResult]) => {
1102 : // --services format: one unit name per line
1103 0 : if (serviceResult.status == 'fulfilled') {
1104 0 : debug("dnf needs-restarting --services succeeded:", serviceResult.value);
1105 0 : serviceResult.value.trim()
1106 0 : .split("\n")
1107 : // HACK: https://issues.redhat.com/browse/RHEL-84657
1108 0 : .filter(line => line.endsWith(".service"))
1109 0 : .forEach(line => restartPackages.daemons.push(line));
1110 0 : } else {
1111 0 : console.error("dnf needs-restarting --services failed:", JSON.stringify(serviceResult.reason));
1112 0 : }
1113 :
1114 : // --reboothint format: " * kernel-rt" plus header/footer; exit nonzero iff reboot required, inverted above
1115 0 : if (rebootResult.status == 'fulfilled') {
1116 0 : debug("dnf needs-restarting --reboothint exited nonzero, wants reboot:", rebootResult.value);
1117 0 : rebootResult.value.split("\n").forEach(line => {
1118 0 : if (line.startsWith(" * "))
1119 0 : restartPackages.reboot.push(line.substring(4));
1120 0 : });
1121 0 : } else {
1122 0 : debug("dnf needs-restarting --reboothint exited zero, no reboot");
1123 0 : }
1124 :
1125 0 : debug("dnf needs-restarting parsed packages:", JSON.stringify(restartPackages));
1126 0 : this.setState({ checkRestartAvailable: true, checkRestartRunning: false, restartPackages });
1127 0 : });
1128 0 : })
1129 0 : .catch(ex => {
1130 : // log the error except for some common cases: no dnf
1131 0 : if (ex.problem !== "not-found" &&
1132 : // plugin does not support --exclude-services
1133 0 : !ex.message.includes("usage:") &&
1134 : // polkit does not allow it
1135 0 : ex.problem !== "access-denied" &&
1136 : // or unprivileged session
1137 0 : ex.problem !== "authentication-failed" &&
1138 : // or the session goes away while checking
1139 0 : ex.problem !== "terminated")
1140 0 : console.error("dnf needs-restarting failed:", ex.toString());
1141 :
1142 : // act like it's not available (demand reboot after every update)
1143 0 : this.setState({ checkRestartAvailable: false, checkRestartRunning: false, restartPackages });
1144 0 : });
1145 0 : }
1146 :
1147 0 : handleLoadError(ex) {
1148 0 : console.warn("loading available updates failed:", JSON.stringify(ex));
1149 :
1150 0 : if (!this._mounted)
1151 0 : return;
1152 :
1153 0 : if (ex.problem === "not-found" || ex.name?.includes("DBus.Error.ServiceUnknown"))
1154 0 : ex = _("PackageKit is not installed");
1155 0 : this.setState(prevState => ({ errorMessages: [...prevState.errorMessages, ex.detail || ex.message || ex], state: "loadError" }));
1156 0 : }
1157 :
1158 19 : loadUpdates() {
1159 19 : let cockpitUpdate = false;
1160 :
1161 19 : this.setState({ state: "loading" });
1162 :
1163 : // check if there is an available version of coreutils; this is a heuristics for unregistered RHEL
1164 : // systems to see if they need a subscription to get "proper" OS updates
1165 19 : this.state.packageManager.is_available(["coreutils"])
1166 19 : .then((have_coreutils) => this.setState({ haveOsRepo: have_coreutils }),
1167 0 : ex => console.warn("Resolving coreutils failed:", JSON.stringify(ex)))
1168 18 : .then(() => this.state.packageManager.get_updates(true, null).then(updates => {
1169 18 : debug("GetUpdates result:", updates);
1170 16 : if (updates.length) {
1171 16 : for (const update of updates) {
1172 3 : if (update.name === 'cockpit-ws') {
1173 3 : cockpitUpdate = true;
1174 : // Arch Linux has no cockpit-ws package
1175 3 : } else if (update.name === 'cockpit' && this.state.backend === "alpm") {
1176 3 : cockpitUpdate = true;
1177 3 : }
1178 16 : }
1179 16 : this.setState({ updates, cockpitUpdate, state: "available" });
1180 8 : } else {
1181 10 : this.setState({ updates: [], state: "uptodate" });
1182 10 : }
1183 18 : this.loadHistory();
1184 18 : }))
1185 19 : .catch(this.handleLoadError);
1186 19 : }
1187 :
1188 18 : async loadHistory() {
1189 18 : try {
1190 18 : const history = await this.state.packageManager.get_history();
1191 18 : this.setState({ history });
1192 3 : } catch (exc) {
1193 3 : console.warn("Failed to load old transactions (history):", exc);
1194 3 : }
1195 18 : }
1196 :
1197 18 : initialLoadOrRefresh() {
1198 0 : watchRedHatSubscription(registered => this.setState({ unregistered: !registered }));
1199 :
1200 5 : cockpit.addEventListener("visibilitychange", () => {
1201 5 : if (!cockpit.hidden)
1202 5 : this.loadOrRefresh(false);
1203 5 : });
1204 :
1205 18 : if (!cockpit.hidden)
1206 2 : this.loadOrRefresh(true);
1207 : else
1208 6 : this.loadUpdates();
1209 18 : }
1210 :
1211 17 : async loadOrRefresh(always_load) {
1212 17 : try {
1213 17 : const seconds = await this.state.packageManager.get_last_refresh_time();
1214 17 : this.setState({ timeSinceRefresh: seconds });
1215 :
1216 : // automatically trigger refresh for ≥ 1 day or if never refreshed
1217 12 : if (seconds >= 24 * 3600 || seconds < 0)
1218 2 : this.handleRefresh();
1219 12 : else if (always_load)
1220 9 : this.loadUpdates();
1221 2 : } catch (exc) {
1222 2 : this.handleLoadError(exc);
1223 2 : }
1224 17 : }
1225 :
1226 12 : watchUpdates(transactionPath) {
1227 12 : this.setState({ state: "applying", applyTransaction: transactionPath, applyTransactionProps: {}, applyActions: [] });
1228 :
1229 12 : return PK.watchTransaction(transactionPath,
1230 12 : {
1231 3 : ErrorCode: (code, details) => this.setState(prevState => ({ errorMessages: [...prevState.errorMessages, details] })),
1232 :
1233 12 : Finished: exit => {
1234 12 : this.setState({ applyTransaction: null, applyTransactionProps: {}, applyActions: [] });
1235 :
1236 10 : if (exit === PK.Enum.EXIT_SUCCESS) {
1237 10 : this.setState({ state: "loading", loadPercent: null });
1238 10 : this.loadHistory().then(() => {
1239 9 : if (this.state.checkRestartAvailable) {
1240 9 : this.checkNeedsRestart()
1241 9 : .finally(() => this.setState({ state: "updateSuccess" }));
1242 1 : } else {
1243 2 : this.setState({ state: "updateSuccess", loadPercent: null });
1244 2 : }
1245 10 : });
1246 1 : } else if (exit === PK.Enum.EXIT_CANCELLED) {
1247 1 : if (this.state.checkRestartAvailable) {
1248 1 : this.setState({ state: "loading", loadPercent: null });
1249 1 : this.checkNeedsRestart();
1250 1 : }
1251 1 : this.loadUpdates();
1252 1 : } else {
1253 : // normally we get FAILED here with ErrorCodes; handle unexpected errors to allow for some debugging
1254 4 : if (exit !== PK.Enum.EXIT_FAILED)
1255 0 : this.setState(prevState => ({ errorMessages: [...prevState.errorMessages, cockpit.format(_("PackageKit reported error code $0"), exit)] }));
1256 4 : this.setState({ state: "updateError" });
1257 4 : }
1258 12 : },
1259 :
1260 : // not working/being used in at least Fedora
1261 0 : RequireRestart: (type, packageId) => console.log("update RequireRestart", type, packageId),
1262 :
1263 10 : Package: (status, packageId) => this.setState(old =>
1264 10 : ({ applyActions: [...old.applyActions, { status, packageId }] })
1265 10 : ),
1266 12 : },
1267 :
1268 12 : notify => this.setState(old =>
1269 12 : ({ applyTransactionProps: { ...old.applyTransactionProps, ...notify } })
1270 12 : )
1271 12 : )
1272 0 : .catch(ex => {
1273 0 : this.setState(prevState => ({ errorMessages: [...prevState.errorMessages, ex], state: "updateError" }));
1274 0 : });
1275 12 : }
1276 :
1277 11 : applyUpdates(type) {
1278 11 : let updates = [...this.state.updates];
1279 11 : if (type === UPDATES.SECURITY)
1280 1 : updates = updates.filter(update => update.severity === Severity.CRITICAL);
1281 1 : if (type === UPDATES.KPATCHES) {
1282 0 : updates = updates.filter(update => isKpatchPackage(update.name));
1283 1 : }
1284 :
1285 11 : PK.transaction()
1286 11 : .then(transactionPath => {
1287 11 : this.watchUpdates(transactionPath)
1288 11 : .then(() => {
1289 11 : PK.update_packages(updates, null, transactionPath)
1290 0 : .catch(ex => {
1291 : // We get more useful error messages through ErrorCode or "PackageKit has crashed", so only
1292 : // show this if we don't have anything else
1293 0 : this.setState(prevState => ({
1294 0 : errorMessages: prevState.errorMessages.length === 0 ? [ex.message] : prevState.errorMessages,
1295 0 : state: "updateError",
1296 0 : }));
1297 0 : });
1298 11 : });
1299 11 : })
1300 0 : .catch(ex => {
1301 0 : this.setState(prevState => ({ errorMessages: [...prevState.errorMessages, ex.message], state: "updateError" }));
1302 0 : });
1303 11 : }
1304 :
1305 19 : renderContent() {
1306 19 : let applySecurity;
1307 19 : let applyKpatches;
1308 19 : let applyAll;
1309 :
1310 : /* On unregistered RHEL systems we need some heuristics: If the "main" OS repos (which provide coreutils) require
1311 : * a subscription, then point this out and don't show available updates, even if there are some auxiliary
1312 : * repositories enabled which don't require subscriptions. But there are a lot of cases (cloud repos, nightly internal
1313 : * repos) which don't need a subscription, there it would just be confusing */
1314 3 : if (this.state.unregistered && this.state.haveOsRepo === false) {
1315 3 : page_status.set_own({
1316 3 : type: "warning",
1317 3 : title: _("Not registered"),
1318 3 : details: {
1319 3 : link: "subscriptions",
1320 3 : }
1321 3 : });
1322 :
1323 3 : return <EmptyStatePanel
1324 3 : title={_("This system is not registered")}
1325 3 : headingLevel="h5"
1326 3 : paragraph={ _("To get software updates, this system needs to be registered with Red Hat, either using the Red Hat Customer Portal or a local subscription server.") }
1327 3 : icon={ExclamationCircleIcon}
1328 3 : action={ _("Register…") }
1329 0 : onAction={ () => cockpit.jump("/subscriptions", cockpit.transport.host) }
1330 3 : />;
1331 3 : }
1332 :
1333 19 : switch (this.state.state) {
1334 19 : case "loading":
1335 19 : case "refreshing":
1336 19 : case "locked":
1337 19 : page_status.set_own({
1338 19 : type: null,
1339 19 : title: _("Checking for package updates..."),
1340 19 : details: {
1341 19 : link: false,
1342 19 : pficon: "spinner",
1343 19 : }
1344 19 : });
1345 :
1346 19 : if (this.state.loadPercent)
1347 3 : return <Progress value={this.state.loadPercent} title={STATE_HEADINGS[this.state.state]} />;
1348 : else
1349 19 : return <EmptyStatePanel loading title={ _("Checking software status")}
1350 19 : headingLevel="h5"
1351 19 : paragraph={STATE_HEADINGS[this.state.state]}
1352 19 : />;
1353 :
1354 16 : case "available":
1355 16 : {
1356 16 : const num_updates = this.state.updates.length;
1357 16 : const num_security_updates = count_security_updates(this.state.updates);
1358 16 : const num_kpatches = count_kpatch_updates(this.state.updates);
1359 16 : const highest_severity = find_highest_severity(this.state.updates);
1360 :
1361 16 : applyAll = (
1362 1 : <Button id={num_updates == num_security_updates ? "install-security" : "install-all"} variant="primary" onClick={ () => this.applyUpdates(UPDATES.ALL) }>
1363 16 : { num_updates == num_security_updates
1364 4 : ? _("Install security updates")
1365 15 : : _("Install all updates") }
1366 16 : </Button>);
1367 :
1368 4 : if (num_security_updates > 0 && num_updates > num_security_updates) {
1369 4 : applySecurity = (
1370 1 : <Button id="install-security" variant="secondary" onClick={ () => this.applyUpdates(UPDATES.SECURITY) }>
1371 4 : {_("Install security updates")}
1372 4 : </Button>);
1373 4 : }
1374 :
1375 3 : if (num_kpatches > 0) {
1376 3 : applyKpatches = (
1377 0 : <Button id="install-kpatches" variant="secondary" onClick={ () => this.applyUpdates(UPDATES.KPATCHES) }>
1378 3 : {_("Install kpatch updates")}
1379 3 : </Button>);
1380 3 : }
1381 :
1382 16 : let text;
1383 16 : if (highest_severity == Severity.CRITICAL)
1384 4 : text = _("Security updates available");
1385 15 : else if (highest_severity >= Severity.IMPORTANT)
1386 3 : text = _("Bug fix updates available");
1387 3 : else if (highest_severity >= Severity.LOW)
1388 3 : text = _("Enhancement updates available");
1389 : else
1390 3 : text = _("Updates available");
1391 :
1392 16 : page_status.set_own({
1393 4 : type: num_security_updates > 0 ? "warning" : "info",
1394 16 : title: text,
1395 16 : details: {
1396 16 : pficon: getPageStatusSeverityIcon(highest_severity)
1397 16 : }
1398 16 : });
1399 :
1400 16 : return (
1401 16 : <>
1402 16 : <PageSection hasBodyWrapper={false}>
1403 16 : <Gallery className='ct-cards-grid' hasGutter>
1404 16 : <CardsPage handleRefresh={this.handleRefresh}
1405 16 : applySecurity={applySecurity}
1406 16 : applyAll={applyAll}
1407 16 : applyKpatches={applyKpatches}
1408 16 : highestSeverity={highest_severity}
1409 16 : onValueChanged={this.onValueChanged}
1410 16 : {...this.state} />
1411 16 : </Gallery>
1412 16 : </PageSection>
1413 16 : { this.state.showRestartServicesDialog &&
1414 3 : <RestartServices
1415 3 : restartPackages={this.state.restartPackages}
1416 0 : close={() => this.setState({ showRestartServicesDialog: false })}
1417 3 : state={this.state.state}
1418 3 : checkNeedsRestart={this.checkNeedsRestart}
1419 0 : onValueChanged={delta => this.setState(delta)}
1420 3 : loadUpdates={this.loadUpdates} />
1421 : }
1422 16 : { this.state.showRebootSystemDialog &&
1423 0 : <ShutdownModal onClose={() => this.setState({ showRebootSystemDialog: false })} />
1424 : }
1425 16 : </>
1426 : );
1427 16 : }
1428 :
1429 3 : case "loadError":
1430 6 : case "updateError":
1431 6 : page_status.set_own({
1432 6 : type: "error",
1433 6 : title: STATE_HEADINGS[this.state.state],
1434 6 : });
1435 6 : return (
1436 6 : <Stack>
1437 6 : <EmptyStatePanel title={ STATE_HEADINGS[this.state.state] }
1438 6 : icon={ ExclamationCircleIcon }
1439 6 : paragraph={
1440 6 : <Content component={ContentVariants.p}>
1441 6 : {_("Please resolve the issue and reload this page.")}
1442 6 : </Content>
1443 : }
1444 6 : />
1445 6 : <CodeBlock className='pf-v6-u-mx-auto error-log'>
1446 6 : <CodeBlockCode>
1447 6 : {this.state.errorMessages
1448 0 : .filter((m, index) => index == 0 || m != this.state.errorMessages[index - 1])
1449 3 : .map(m => <span key={m}>{m}</span>)}
1450 6 : </CodeBlockCode>
1451 6 : </CodeBlock>
1452 6 : </Stack>
1453 : );
1454 :
1455 13 : case "applying":
1456 13 : page_status.set_own(null);
1457 13 : return <ApplyUpdates transactionProps={this.state.applyTransactionProps}
1458 13 : actions={this.state.applyActions}
1459 0 : onCancel={ () => PK.call(this.state.applyTransaction, PK.transactionInterface, "Cancel", []) }
1460 13 : rebootAfter={this.state.rebootAfterSuccess}
1461 1 : setRebootAfter={ (_event, enabled) => this.setState({ rebootAfterSuccess: enabled }) }
1462 13 : />;
1463 :
1464 11 : case "updateSuccess": {
1465 4 : if (this.state.rebootAfterSuccess) {
1466 4 : this.setState({ state: "restart" });
1467 4 : cockpit.spawn(["shutdown", "--reboot", "now"], { superuser: "require" });
1468 4 : return null;
1469 4 : }
1470 :
1471 10 : let warningTitle;
1472 4 : if (!this.state.checkRestartAvailable) {
1473 4 : warningTitle = _("Reboot recommended");
1474 3 : } else {
1475 9 : if (this.state.restartPackages.reboot.length > 0)
1476 3 : warningTitle = cockpit.ngettext("A package needs a system reboot for the updates to take effect:",
1477 3 : "Some packages need a system reboot for the updates to take effect:",
1478 3 : this.state.restartPackages.reboot.length);
1479 9 : else if (this.state.restartPackages.daemons.length > 0)
1480 4 : warningTitle = cockpit.ngettext("A service needs to be restarted for the updates to take effect:",
1481 4 : "Some services need to be restarted for the updates to take effect:",
1482 4 : this.state.restartPackages.daemons.length);
1483 9 : else if (this.state.restartPackages.manual.length > 0)
1484 3 : warningTitle = _("Some software needs to be restarted manually");
1485 9 : }
1486 :
1487 5 : if (warningTitle) {
1488 5 : page_status.set_own({
1489 5 : type: "warning",
1490 5 : title: warningTitle
1491 5 : });
1492 5 : }
1493 :
1494 10 : return (
1495 10 : <>
1496 10 : <UpdateSuccess onIgnore={this.loadUpdates}
1497 1 : openServiceRestartDialog={() => this.setState({ showRestartServicesDialog: true })}
1498 0 : openRebootDialog={() => this.setState({ showRebootSystemDialog: true })}
1499 10 : restart={this.state.restartPackages.daemons}
1500 10 : manual={this.state.restartPackages.manual}
1501 10 : reboot={this.state.restartPackages.reboot}
1502 10 : checkRestartAvailable={this.state.checkRestartAvailable}
1503 10 : history={this.state.history} />
1504 10 : { this.state.showRebootSystemDialog &&
1505 0 : <ShutdownModal onClose={() => this.setState({ showRebootSystemDialog: false })} />
1506 : }
1507 11 : { this.state.showRestartServicesDialog &&
1508 4 : <RestartServices restartPackages={this.state.restartPackages}
1509 0 : close={() => this.setState({ showRestartServicesDialog: false })}
1510 4 : state={this.state.state}
1511 4 : checkNeedsRestart={this.checkNeedsRestart}
1512 0 : onValueChanged={delta => this.setState(delta)}
1513 4 : loadUpdates={this.loadUpdates} />
1514 : }
1515 11 : </>
1516 : );
1517 11 : }
1518 :
1519 4 : case "restart":
1520 4 : page_status.set_own(null);
1521 4 : return <EmptyStatePanel loading title={ _("Restarting") }
1522 4 : headingLevel="h5"
1523 4 : paragraph={ _("Your server will close the connection soon. You can reconnect after it has restarted.") }
1524 4 : />;
1525 :
1526 10 : case "uptodate":
1527 10 : {
1528 10 : page_status.set_own({
1529 10 : title: STATE_HEADINGS[this.state.state],
1530 10 : details: {
1531 10 : link: false,
1532 10 : pficon: "check",
1533 10 : }
1534 10 : });
1535 :
1536 10 : return (
1537 10 : <PageSection hasBodyWrapper={false}>
1538 10 : <Gallery className='ct-cards-grid' hasGutter>
1539 10 : <CardsPage onValueChanged={this.onValueChanged} handleRefresh={this.handleRefresh} {...this.state} />
1540 10 : </Gallery>
1541 10 : { this.state.showRestartServicesDialog &&
1542 3 : <RestartServices restartPackages={this.state.restartPackages}
1543 1 : close={() => this.setState({ showRestartServicesDialog: false })}
1544 3 : state={this.state.state}
1545 3 : checkNeedsRestart={this.checkNeedsRestart}
1546 1 : onValueChanged={delta => this.setState(delta)}
1547 3 : loadUpdates={this.loadUpdates} />
1548 : }
1549 10 : { this.state.showRebootSystemDialog &&
1550 0 : <ShutdownModal onClose={() => this.setState({ showRebootSystemDialog: false })} />
1551 : }
1552 10 : </PageSection>
1553 : );
1554 10 : }
1555 :
1556 3 : default:
1557 3 : page_status.set_own(null);
1558 3 : return null;
1559 19 : }
1560 19 : }
1561 :
1562 10 : handleRefresh() {
1563 10 : this.setState({ state: "refreshing", loadPercent: null });
1564 10 : this.state.packageManager.refresh(true, data => this.setState({ loadPercent: data.percentage }))
1565 10 : .then(() => {
1566 10 : if (this._mounted === false)
1567 10 : return;
1568 :
1569 10 : this.setState({ timeSinceRefresh: 0 });
1570 10 : this.loadUpdates();
1571 10 : })
1572 10 : .catch(this.handleLoadError);
1573 10 : }
1574 :
1575 19 : render() {
1576 19 : let content = this.renderContent();
1577 19 : if (!["available", "uptodate"].includes(this.state.state))
1578 19 : content = <PageSection hasBodyWrapper={false}>{content}</PageSection>;
1579 :
1580 19 : return (
1581 19 : <WithDialogs>
1582 19 : <Page className="pf-m-no-sidebar">
1583 19 : {content}
1584 19 : </Page>
1585 19 : </WithDialogs>
1586 : );
1587 19 : }
1588 19 : }
1589 :
1590 19 : document.addEventListener("DOMContentLoaded", async () => {
1591 19 : init();
1592 :
1593 19 : try {
1594 19 : await cockpit.init();
1595 3 : } catch (exp) {
1596 : /* Remove this when every beiboot scenario has Cockpit 336 */
1597 3 : if (exp.problem === 'not-supported') {
1598 3 : const os_release = await read_os_release();
1599 3 : cockpit.info.os_release = os_release;
1600 3 : }
1601 3 : }
1602 :
1603 19 : const root = createRoot(document.getElementById('app'));
1604 19 : root.render(<OsUpdates />);
1605 19 : });
|