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