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 16 : function deduplicate(list) {
111 16 : return [...new Set(list)].sort();
112 16 : }
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 13 : 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 16 : function shortenCockpitWsInstance(list) {
139 1 : return list.map(item => item.startsWith('cockpit-wsinstance-https') ? 'cockpit-wsinstance-https@.' : item);
140 16 : }
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 13 : 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 13 : 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 3 : } else {
327 13 : description = <div className="changelog">{info.description}</div>;
328 13 : }
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 : restartInProgress: false,
432 2 : };
433 :
434 2 : this.dialogErrorSet = this.dialogErrorSet.bind(this);
435 2 : this.dialogErrorDismiss = this.dialogErrorDismiss.bind(this);
436 2 : this.restart = this.restart.bind(this);
437 2 : }
438 :
439 1 : dialogErrorSet(text, detail) {
440 1 : this.setState({ dialogError: text, dialogErrorDetail: detail });
441 1 : }
442 :
443 0 : dialogErrorDismiss() {
444 0 : this.setState({ dialogError: undefined });
445 0 : }
446 :
447 2 : 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 2 : const restarts = daemons.map(service => cockpit.spawn(["systemctl", "restart", service], { superuser: "require", err: "message" }));
457 2 : this.setState({ restartInProgress: true });
458 2 : Promise.all(restarts)
459 1 : .then(() => {
460 1 : this.props.onValueChanged({ restartPackages: { reboot: this.props.restartPackages.reboot, daemons: [], manual: this.props.restartPackages.manual } });
461 1 : if (this.props.state === "updateSuccess")
462 1 : this.props.loadUpdates();
463 1 : this.setState({ restartInProgress: false });
464 1 : this.props.close();
465 1 : })
466 1 : .catch(ex => {
467 1 : this.dialogErrorSet(_("Failed to restart service"), ex.message);
468 : // see what services remain
469 1 : this.props.checkNeedsRestart();
470 1 : });
471 2 : }
472 :
473 2 : render() {
474 2 : let body;
475 1 : if (this.props.checkRestartRunning) {
476 1 : body = (
477 1 : <Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
478 1 : <Spinner size="sm" />
479 1 : <p>{_("Reloading the state of remaining services")}</p>
480 1 : </Flex>
481 : );
482 1 : } else if (this.props.restartPackages.daemons.length > 0) {
483 2 : body = (<>
484 2 : {cockpit.ngettext("The following service will be restarted:", "The following services will be restarted:", this.props.restartPackages.daemons.length)}
485 2 : <TwoColumnContent list={this.props.restartPackages.daemons} flexClassName="restart-services-modal-body" />
486 2 : </>);
487 2 : }
488 :
489 2 : return (
490 2 : <Modal id="restart-services-modal" isOpen
491 2 : position="top"
492 2 : variant="medium"
493 2 : onClose={this.props.close}>
494 2 : <ModalHeader title={_("Restart services")} />
495 2 : <ModalBody>
496 2 : <Stack hasGutter>
497 2 : {this.state.dialogError && <ModalError dialogError={this.state.dialogError} dialogErrorDetail={this.state.dialogErrorDetail} />}
498 2 : <StackItem>{body}</StackItem>
499 2 : </Stack>
500 2 : </ModalBody>
501 2 : <ModalFooter>
502 2 : {this.props.restartPackages.daemons.includes("cockpit") &&
503 1 : <Alert variant="warning"
504 1 : title={_("Web Console will restart")}
505 1 : isInline>
506 1 : <p>
507 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.")}
508 1 : </p>
509 1 : </Alert>}
510 2 : <Button variant='primary'
511 2 : isDisabled={ this.state.restartInProgress }
512 2 : onClick={ this.restart }>
513 2 : {_("Restart services")}
514 2 : </Button>
515 2 : <Button variant='link' className='btn-cancel' onClick={ this.props.close }>
516 2 : {_("Cancel")}
517 2 : </Button>
518 2 : </ModalFooter>
519 2 : </Modal>
520 : );
521 2 : }
522 19 : }
523 :
524 12 : const formatPackageId = packageId => {
525 12 : const pfields = packageId.split(";");
526 12 : return pfields[0] + " " + pfields[1] + " (" + pfields[2] + ")";
527 12 : };
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 12 : const ApplyUpdates = ({ transactionProps, actions, onCancel, rebootAfter, setRebootAfter }) => {
532 12 : const remain = transactionProps.RemainingTime
533 2 : ? timeformat.distanceToNow(new Date().valueOf() + transactionProps.RemainingTime * 1000)
534 12 : : null;
535 :
536 12 : let percentage = transactionProps.Percentage || 0;
537 : // PackageKit sets this to 101 initially
538 12 : if (percentage > 100)
539 12 : percentage = 0;
540 :
541 : // scroll update log to the bottom, if it already is (almost) at the bottom
542 12 : const log = document.getElementById("update-log");
543 11 : if (log) {
544 11 : if (log.scrollHeight - log.clientHeight <= log.scrollTop + 2)
545 11 : log.scrollTop = log.scrollHeight;
546 11 : }
547 :
548 12 : const cancelButton = transactionProps.AllowCancel
549 12 : ? <Button variant="secondary" onClick={onCancel} size="sm">{_("Cancel")}</Button>
550 12 : : null;
551 :
552 12 : if (actions.length === 0 && percentage === 0) {
553 12 : return <EmptyStatePanel title={ _("Initializing...") }
554 12 : headingLevel="h5"
555 12 : secondary={cancelButton}
556 12 : loading
557 12 : />;
558 12 : }
559 :
560 12 : const lastAction = actions[actions.length - 1];
561 : // when resuming an upgrade, we did not get any Package signal yet; fall back to LastPackage
562 11 : const curPackage = formatPackageId(lastAction?.packageId || transactionProps.LastPackage || "");
563 12 : return (
564 12 : <div className="progress-main-view">
565 12 : <Grid hasGutter>
566 12 : <GridItem span={12}>
567 12 : <div className="progress-description pf-v6-u-display-flex">
568 12 : <Spinner size="md" isInline />
569 11 : <strong>{PK_STATUS_STRINGS[lastAction?.status] || PK_STATUS_STRINGS[PK.Enum.STATUS_UPDATE]}</strong>
570 :
571 12 : <Truncate content={curPackage} />
572 12 : </div>
573 12 : <Progress title={remain}
574 2 : aria-label={remain ? _("Time remaining: ") : _("Update progress")}
575 12 : value={percentage}
576 12 : size={ProgressSize.sm}
577 12 : className="pf-v6-u-mb-xs" />
578 12 : </GridItem>
579 :
580 12 : <GridItem span={3}>{cancelButton}</GridItem>
581 :
582 12 : <GridItem span={12}>
583 12 : <Switch id="reboot-after" isChecked={rebootAfter}
584 12 : label={ _("Reboot after completion") }
585 12 : onChange={setRebootAfter} />
586 12 : </GridItem>
587 :
588 12 : <GridItem span={12} className="update-log">
589 3 : <ExpandableSection toggleText={_("View update log")} onToggle={() => {
590 : // always scroll down on expansion
591 3 : const log = document.getElementById("update-log");
592 3 : log.scrollTop = log.scrollHeight;
593 3 : }}>
594 12 : <div id="update-log" className="update-log-content">
595 12 : <table>
596 12 : <tbody>
597 9 : { actions.slice(0, -1).map((action, i) => (
598 9 : <tr key={action.packageId + i}>
599 1 : <th>{PK_STATUS_LOG_STRINGS[action.status] || PK_STATUS_LOG_STRINGS[PK.Enum.STATUS_UPDATE]}</th>
600 9 : <td>{formatPackageId(action.packageId)}</td>
601 9 : </tr>)) }
602 12 : </tbody>
603 12 : </table>
604 12 : </div>
605 12 : </ExpandableSection>
606 12 : </GridItem>
607 12 : </Grid>
608 12 : </div>
609 : );
610 12 : };
611 :
612 2 : const TwoColumnContent = ({ list, flexClassName }) => {
613 2 : const half = Math.round(list.length / 2);
614 2 : const col1 = list.slice(0, half);
615 2 : const col2 = list.slice(half);
616 2 : return (
617 2 : <Flex className={flexClassName}>
618 2 : <FlexItem flex={{ default: 'flex_1' }}>
619 2 : <Content component="ul">
620 2 : {col1.map(item => (<Content component="li" key={item}>{item}</Content>))}
621 2 : </Content>
622 2 : </FlexItem>
623 1 : {col2.length > 0 && <FlexItem flex={{ default: 'flex_1' }}>
624 1 : <Content component="ul">
625 0 : {col2.map(item => (<Content component="li" key={item}>{item}</Content>))}
626 1 : </Content>
627 1 : </FlexItem>}
628 2 : </Flex>
629 : );
630 2 : };
631 :
632 2 : const TwoColumnTitle = ({ icon, str }) => {
633 2 : return (<>
634 2 : {icon}
635 2 : <span className="update-success-table-title">
636 2 : {str}
637 2 : </span>
638 2 : </>);
639 2 : };
640 :
641 9 : const UpdateSuccess = ({ onIgnore, openServiceRestartDialog, openRebootDialog, restart, manual, reboot, checkRestartAvailable, history }) => {
642 2 : 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 1 : const pkgs = Object.keys(history[0]?.packages ?? {});
646 1 : 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 1 : const paragraph = only_kpatch ? null : _("Updated packages may require a reboot to take effect.");
650 2 : const actions = only_kpatch
651 1 : ? <Button id="ignore" variant="primary" onClick={onIgnore}>{_("Continue")}</Button>
652 2 : : <>
653 2 : <Button id="reboot-system" variant="primary" onClick={openRebootDialog}>{_("Reboot system...")}</Button>
654 2 : <Button id="ignore" variant="link" onClick={onIgnore}>{_("Ignore")}</Button>
655 2 : </>;
656 :
657 2 : return (<>
658 2 : <EmptyStatePanel icon={RebootingIcon}
659 2 : title={ _("Update was successful") }
660 2 : headingLevel="h5"
661 2 : paragraph={paragraph}
662 2 : secondary={actions} />
663 2 : { history[0]?.packages &&
664 2 : <div className="flow-list-blank-slate">
665 2 : <ExpandableSection toggleText={_("Package information")}>
666 2 : <PackageList packages={history[0].packages} />
667 2 : </ExpandableSection>
668 2 : </div>
669 : }
670 2 : </>);
671 2 : }
672 :
673 8 : const entries = [];
674 1 : if (reboot.length > 0) {
675 1 : entries.push({
676 1 : columns: [
677 1 : {
678 1 : title: <TwoColumnTitle icon={<RebootingIcon />}
679 1 : str={cockpit.format(cockpit.ngettext("$0 package needs a system reboot",
680 1 : "$0 packages need a system reboot",
681 1 : reboot.length),
682 1 : reboot.length)} />
683 1 : },
684 1 : ],
685 1 : props: { key: "reboot", id: "reboot-row" },
686 1 : hasPadding: true,
687 1 : expandedContent: <TwoColumnContent list={reboot} />,
688 1 : });
689 1 : }
690 :
691 2 : if (restart.length > 0) {
692 2 : entries.push({
693 2 : columns: [
694 2 : {
695 2 : title: <TwoColumnTitle icon={<ProcessAutomationIcon />}
696 2 : str={cockpit.format(cockpit.ngettext("$0 service needs to be restarted",
697 2 : "$0 services need to be restarted",
698 2 : restart.length),
699 2 : restart.length)} />
700 2 : },
701 2 : ],
702 2 : props: { key: "service", id: "service-row" },
703 2 : hasPadding: true,
704 2 : expandedContent: <TwoColumnContent list={restart} />,
705 2 : });
706 2 : }
707 :
708 1 : if (manual.length > 0) {
709 1 : entries.push({
710 1 : columns: [
711 1 : {
712 1 : title: <TwoColumnTitle icon={<ProcessAutomationIcon />}
713 1 : str={_("Some software needs to be restarted manually")} />
714 1 : }
715 1 : ],
716 1 : props: { key: "manual", id: "manual-row" },
717 1 : hasPadding: true,
718 1 : expandedContent: <TwoColumnContent list={manual} />,
719 1 : });
720 1 : }
721 :
722 8 : const showReboot = reboot.length > 0 || manual.length > 0;
723 :
724 9 : return (<>
725 9 : <EmptyStatePanel title={ _("Update was successful") }
726 9 : headingLevel="h5"
727 9 : secondary={
728 9 : <>
729 2 : { entries.length > 0 && <ListingTable aria-label={_("Update Success Table")}
730 2 : columns={[{ title: _("Info") }]}
731 2 : showHeader={false}
732 2 : className="updates-success-table"
733 2 : rows={entries} /> }
734 9 : <div className="update-success-actions">
735 1 : { showReboot && <Button id="reboot-system" variant="primary" onClick={openRebootDialog}>{_("Reboot system...")}</Button> }
736 1 : { restart.length > 0 && <Button id="choose-service" variant={showReboot ? "secondary" : "primary"} onClick={openServiceRestartDialog}>{_("Restart services...")}</Button> }
737 8 : { reboot.length > 0 || restart.length > 0 || manual.length > 0
738 2 : ? <Button id="ignore" variant="link" onClick={onIgnore}>{_("Ignore")}</Button>
739 8 : : <Button id="ignore" variant="primary" onClick={onIgnore}>{_("Continue")}</Button> }
740 9 : </div>
741 9 : </>
742 9 : } />
743 8 : { history[0]?.packages &&
744 8 : <div className="flow-list-blank-slate">
745 8 : <ExpandableSection toggleText={_("Package information")}>
746 8 : <PackageList packages={history[0].packages} />
747 8 : </ExpandableSection>
748 8 : </div>
749 : }
750 9 : </>);
751 9 : };
752 :
753 18 : const UpdatesStatus = ({ updates, highestSeverity, timeSinceRefresh, restartPackages, onValueChanged }) => {
754 18 : const numUpdates = updates.length;
755 18 : const numSecurity = count_security_updates(updates);
756 18 : const numRestartServices = restartPackages.daemons.length;
757 18 : const numManualSoftware = restartPackages.manual.length;
758 18 : const numRebootPackages = restartPackages.reboot.length;
759 18 : let lastChecked;
760 : // PackageKit returns G_MAXUINT if the db was never checked.
761 18 : if (timeSinceRefresh !== null && timeSinceRefresh !== 2 ** 32 - 1)
762 18 : lastChecked = cockpit.format(_("Last checked: $0"), timeformat.distanceToNow(new Date().valueOf() - timeSinceRefresh * 1000));
763 :
764 18 : const notifications = [];
765 16 : if (numUpdates > 0) {
766 4 : if (numUpdates == numSecurity) {
767 4 : const stateStr = cockpit.ngettext("$0 security fix available", "$0 security fixes available", numSecurity);
768 4 : notifications.push({
769 4 : id: "security-updates-available",
770 4 : stateStr: cockpit.format(stateStr, numSecurity),
771 4 : icon: getSeverityIcon(highestSeverity),
772 4 : secondary: <Content id="last-checked" component={ContentVariants.small}>{lastChecked}</Content>
773 4 : });
774 3 : } else {
775 15 : let stateStr = cockpit.ngettext("$0 update available", "$0 updates available", numUpdates);
776 15 : if (numSecurity > 0)
777 4 : stateStr += cockpit.ngettext(", including $1 security fix", ", including $1 security fixes", numSecurity);
778 15 : notifications.push({
779 15 : id: "updates-available",
780 15 : stateStr: cockpit.format(stateStr, numUpdates, numSecurity),
781 15 : icon: getSeverityIcon(highestSeverity),
782 15 : secondary: <Content id="last-checked" component={ContentVariants.small}>{lastChecked}</Content>
783 15 : });
784 15 : }
785 8 : } else if (!numRestartServices && !numRebootPackages && !numManualSoftware) {
786 10 : notifications.push({
787 10 : id: "system-up-to-date",
788 10 : stateStr: STATE_HEADINGS.uptodate,
789 10 : icon: <CheckIcon color="green" />,
790 10 : secondary: <Content id="last-checked" component={ContentVariants.small}>{lastChecked}</Content>
791 10 : });
792 10 : }
793 :
794 3 : if (numRebootPackages > 0) {
795 3 : const stateStr = cockpit.ngettext("$0 package needs a system reboot", "$0 packages need a system reboot", numRebootPackages);
796 3 : notifications.push({
797 3 : id: "packages-need-reboot",
798 3 : stateStr: cockpit.format(stateStr, numRebootPackages),
799 3 : icon: <RebootingIcon />,
800 0 : secondary: <Button variant="danger" onClick={() => onValueChanged("showRebootSystemDialog", true)}>
801 3 : {_("Reboot system...")}
802 3 : </Button>
803 3 : });
804 3 : }
805 :
806 3 : if (numRestartServices > 0) {
807 3 : const stateStr = cockpit.ngettext("$0 service needs to be restarted", "$0 services need to be restarted", numRestartServices);
808 3 : notifications.push({
809 3 : id: "services-need-restart",
810 3 : stateStr: cockpit.format(stateStr, numRestartServices),
811 3 : icon: <ProcessAutomationIcon />,
812 1 : secondary: <Button variant="primary" onClick={() => onValueChanged("showRestartServicesDialog", true)}>
813 3 : {_("Restart services...")}
814 3 : </Button>
815 3 : });
816 3 : }
817 :
818 3 : if (numManualSoftware > 0) {
819 3 : notifications.push({
820 3 : id: "processes-need-restart",
821 3 : stateStr: _("Some software needs to be restarted manually"),
822 3 : icon: <ProcessAutomationIcon />,
823 3 : secondary: <Content component={ContentVariants.small}>{restartPackages.manual.join(", ")}</Content>
824 3 : });
825 3 : }
826 :
827 18 : return (<Stack hasGutter>
828 18 : { notifications.map(notification => (
829 18 : <StackItem key={notification.id}>
830 18 : <Flex flexWrap={{ default: 'nowrap' }} id={notification.id}>
831 18 : <FlexItem>
832 18 : {notification.icon}
833 18 : </FlexItem>
834 18 : <FlexItem>
835 18 : <Stack>
836 18 : <StackItem>
837 18 : <Content component={ContentVariants.p}>{notification.stateStr}</Content>
838 18 : </StackItem>
839 18 : <StackItem>
840 18 : { notification.secondary }
841 18 : </StackItem>
842 18 : </Stack>
843 18 : </FlexItem>
844 18 : </Flex>
845 18 : </StackItem>
846 18 : ))}
847 18 : </Stack>);
848 18 : };
849 :
850 19 : class CardsPage extends React.Component {
851 18 : constructor() {
852 18 : super();
853 18 : this.state = {
854 18 : autoupdates_backend: undefined,
855 18 : };
856 18 : }
857 :
858 18 : componentDidMount() {
859 18 : getBackend(this.props.backend).then(b => { this.setState({ autoupdates_backend: b }) });
860 18 : }
861 :
862 18 : render() {
863 18 : const cardContents = [];
864 18 : let settingsContent = null;
865 18 : const statusContent = <Stack hasGutter>
866 18 : <UpdatesStatus key="updates-status"
867 18 : updates={this.props.updates}
868 18 : onValueChanged={this.props.onValueChanged}
869 18 : restartPackages={this.props.restartPackages}
870 18 : highestSeverity={this.props.highestSeverity}
871 18 : timeSinceRefresh={this.props.timeSinceRefresh} />
872 18 : <KpatchStatus />
873 18 : </Stack>;
874 :
875 18 : if (this.state.autoupdates_backend) {
876 18 : settingsContent = <Stack hasGutter>
877 18 : <AutoUpdates privileged={this.props.privileged} packagekit_backend={this.props.backend} initial_backend={this.state.autoupdates_backend} />
878 18 : {cockpit.info.os_release && cockpit.info.os_release?.ID === "rhel" &&
879 3 : <KpatchSettings privileged={this.props.privileged} />
880 : }
881 18 : </Stack>;
882 18 : }
883 :
884 18 : cardContents.push({
885 18 : id: "status",
886 18 : className: settingsContent !== null ? "ct-card-info" : "",
887 18 : title: _("Status"),
888 18 : actions: (<Tooltip content={_("Check for updates")}>
889 18 : <Button icon={<RedoIcon />} variant="secondary" onClick={this.props.handleRefresh} />
890 18 : </Tooltip>),
891 18 : body: statusContent,
892 18 : });
893 :
894 18 : if (settingsContent !== null) {
895 18 : cardContents.push({
896 18 : id: "settings",
897 18 : className: "ct-card-info",
898 18 : title: _("Settings"),
899 18 : body: settingsContent,
900 18 : });
901 18 : }
902 :
903 16 : if (this.props.state === "available") { // automatic updates are not tracked by PackageKit, hide history when they are enabled
904 16 : cardContents.push({
905 16 : id: "available-updates",
906 16 : title: _("Available updates"),
907 16 : actions: (<div className="pk-updates--header--actions">
908 16 : {this.props.cockpitUpdate &&
909 3 : <Flex flex={{ default: 'inlineFlex' }} className="cockpit-update-warning">
910 3 : <FlexItem>
911 3 : <ExclamationTriangleIcon className="ct-icon-exclamation-triangle cockpit-update-warning-icon" />
912 3 : <strong className="cockpit-update-warning-text">
913 3 : <span className="pf-screen-reader">{_("Danger alert:")}</span>
914 3 : {_("Web Console will restart")}
915 3 : </strong>
916 3 : </FlexItem>
917 3 : <FlexItem>
918 3 : <Popover aria-label="More information popover"
919 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.")}>
920 3 : <Button variant="link" isInline>{_("More info...")}</Button>
921 3 : </Popover>
922 3 : </FlexItem>
923 3 : </Flex>}
924 16 : {this.props.applyKpatches}
925 16 : {this.props.applySecurity}
926 16 : {this.props.applyAll}
927 16 : </div>),
928 16 : containsList: true,
929 16 : body: <UpdatesList updates={this.props.updates} />
930 16 : });
931 16 : }
932 :
933 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
934 8 : cardContents.push({
935 8 : id: "update-history",
936 8 : title: _("Update history"),
937 8 : containsList: true,
938 8 : body: <History packagekit={this.props.history} />
939 8 : });
940 8 : }
941 :
942 18 : return cardContents.map(card => {
943 18 : return (
944 18 : <Card key={card.id} className={card.className} id={card.id}>
945 18 : <CardHeader actions={{ actions: card.actions }}>
946 18 : <CardTitle component="h2">{card.title}</CardTitle>
947 18 : </CardHeader>
948 16 : <CardBody className={card.containsList ? "contains-list" : null}>
949 18 : {card.body}
950 18 : </CardBody>
951 18 : </Card>
952 : );
953 18 : });
954 18 : }
955 19 : }
956 :
957 19 : class OsUpdates extends React.Component {
958 19 : constructor() {
959 19 : super();
960 19 : this.state = {
961 19 : state: "loading",
962 19 : errorMessages: [],
963 19 : updates: [],
964 19 : timeSinceRefresh: null,
965 19 : loadPercent: null,
966 19 : cockpitUpdate: false,
967 19 : haveOsRepo: null,
968 19 : applyTransaction: null,
969 19 : applyTransactionProps: {},
970 19 : applyActions: [],
971 19 : history: [],
972 19 : unregistered: false,
973 19 : privileged: false,
974 19 : autoUpdatesEnabled: undefined,
975 19 : restartPackages: { daemons: [], manual: [], reboot: [] },
976 19 : checkRestartAvailable: false,
977 19 : checkRestartRunning: false,
978 19 : showRestartServicesDialog: false,
979 19 : showRebootSystemDialog: false,
980 19 : backend: "",
981 19 : rebootAfterSuccess: false,
982 19 : packageManager: null,
983 19 : };
984 19 : this.handleLoadError = this.handleLoadError.bind(this);
985 19 : this.handleRefresh = this.handleRefresh.bind(this);
986 19 : this.loadUpdates = this.loadUpdates.bind(this);
987 19 : this.onValueChanged = this.onValueChanged.bind(this);
988 19 : this.checkNeedsRestart = this.checkNeedsRestart.bind(this);
989 19 : }
990 :
991 1 : onValueChanged(key, value) {
992 1 : this.setState({ [key]: value });
993 1 : }
994 :
995 19 : async componentDidMount() {
996 19 : this._mounted = true;
997 19 : this.checkNeedsRestart();
998 :
999 19 : superuser.addEventListener("changed", this.handleSuperUserChange);
1000 :
1001 : // HACK: force usage of PackageKit backend
1002 19 : const packageManager = await getPackageManager(true);
1003 19 : const backend = await packageManager.get_backend();
1004 19 : this.setState({ packageManager, backend });
1005 :
1006 : // check if there is an upgrade in progress already; if so, switch to "applying" state right away
1007 19 : PK.call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "GetTransactionList", [])
1008 19 : .then(([transactions]) => {
1009 19 : if (!this._mounted)
1010 19 : return;
1011 :
1012 1 : const promises = transactions.map(transactionPath => PK.call(
1013 1 : transactionPath, "org.freedesktop.DBus.Properties", "Get", [PK.transactionInterface, "Role"]));
1014 :
1015 19 : Promise.all(promises)
1016 19 : .then(roles => {
1017 : // any transaction with UPDATE_PACKAGES role?
1018 3 : for (let idx = 0; idx < roles.length; ++idx) {
1019 3 : if (roles[idx][0].v === PK.Enum.ROLE_UPDATE_PACKAGES) {
1020 3 : this.watchUpdates(transactions[idx]);
1021 3 : return;
1022 3 : }
1023 3 : }
1024 :
1025 : // no running updates found, proceed to showing available updates
1026 19 : this.initialLoadOrRefresh();
1027 19 : })
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 19 : })
1034 19 : .catch(this.handleLoadError);
1035 19 : }
1036 :
1037 0 : componentWillUnmount() {
1038 0 : this._mounted = false;
1039 0 : superuser.removeEventListener("changed", this.handleSuperUserChange);
1040 0 : }
1041 :
1042 18 : handleSuperUserChange = () => {
1043 18 : this.setState({ privileged: superuser.allowed });
1044 : // get out of error state when switching from unprivileged to privileged
1045 18 : if (superuser.allowed && this.state.state.indexOf("Error") >= 0)
1046 4 : this.loadUpdates();
1047 18 : };
1048 :
1049 19 : checkNeedsRestart() {
1050 19 : this.setState({ checkRestartRunning: true });
1051 19 : return python.spawn(callTracerScript, undefined, { err: "message", superuser: "require" })
1052 16 : .then(output => {
1053 16 : debug("tracer succeeded, output:", output);
1054 16 : const restartPackages = JSON.parse(output);
1055 : // Filter out duplicates
1056 16 : restartPackages.reboot = deduplicate(shortenCockpitWsInstance(restartPackages.reboot));
1057 16 : restartPackages.daemons = deduplicate(shortenCockpitWsInstance(restartPackages.daemons));
1058 16 : restartPackages.manual = deduplicate(shortenCockpitWsInstance(restartPackages.manual));
1059 16 : debug("tracer parsed restartPackages:", JSON.stringify(restartPackages));
1060 16 : this.setState({ checkRestartAvailable: true, checkRestartRunning: false, restartPackages });
1061 16 : })
1062 2 : .catch((exception, data) => {
1063 : // tracer not installed or supported (like on Arch)? then fall back to dnf needs-restarting
1064 2 : 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 2 : 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 2 : this.setState({
1082 2 : checkRestartAvailable: false,
1083 2 : checkRestartRunning: false,
1084 2 : restartPackages: { reboot: [], daemons: [], manual: [] },
1085 2 : });
1086 2 : });
1087 19 : }
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 19 : loadUpdates() {
1169 19 : let cockpitUpdate = false;
1170 :
1171 19 : 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 19 : this.state.packageManager.is_available(["coreutils"])
1176 18 : .then((have_coreutils) => this.setState({ haveOsRepo: have_coreutils }),
1177 0 : ex => console.warn("Resolving coreutils failed:", JSON.stringify(ex)))
1178 18 : .then(() => this.state.packageManager.get_updates(true, null).then(updates => {
1179 18 : debug("GetUpdates result:", updates);
1180 16 : if (updates.length) {
1181 16 : for (const update of updates) {
1182 3 : if (update.name === 'cockpit-ws') {
1183 3 : cockpitUpdate = true;
1184 : // Arch Linux has no cockpit-ws package
1185 3 : } else if (update.name === 'cockpit' && this.state.backend === "alpm") {
1186 3 : cockpitUpdate = true;
1187 3 : }
1188 16 : }
1189 16 : this.setState({ updates, cockpitUpdate, state: "available" });
1190 8 : } else {
1191 10 : this.setState({ updates: [], state: "uptodate" });
1192 10 : }
1193 18 : this.loadHistory();
1194 18 : }))
1195 19 : .catch(this.handleLoadError);
1196 19 : }
1197 :
1198 18 : async loadHistory() {
1199 18 : try {
1200 18 : const history = await this.state.packageManager.get_history();
1201 18 : this.setState({ history });
1202 3 : } catch (exc) {
1203 3 : console.warn("Failed to load old transactions (history):", exc);
1204 3 : }
1205 18 : }
1206 :
1207 18 : initialLoadOrRefresh() {
1208 0 : watchRedHatSubscription(registered => this.setState({ unregistered: !registered }));
1209 :
1210 5 : cockpit.addEventListener("visibilitychange", () => {
1211 5 : if (!cockpit.hidden)
1212 5 : this.loadOrRefresh(false);
1213 5 : });
1214 :
1215 18 : if (!cockpit.hidden)
1216 2 : this.loadOrRefresh(true);
1217 : else
1218 6 : this.loadUpdates();
1219 18 : }
1220 :
1221 17 : async loadOrRefresh(always_load) {
1222 17 : try {
1223 17 : const seconds = await this.state.packageManager.get_last_refresh_time();
1224 17 : this.setState({ timeSinceRefresh: seconds });
1225 :
1226 : // automatically trigger refresh for ≥ 1 day or if never refreshed
1227 12 : if (seconds >= 24 * 3600 || seconds < 0)
1228 2 : this.handleRefresh();
1229 12 : else if (always_load)
1230 9 : this.loadUpdates();
1231 2 : } catch (exc) {
1232 2 : this.handleLoadError(exc);
1233 2 : }
1234 17 : }
1235 :
1236 12 : watchUpdates(transactionPath) {
1237 12 : this.setState({ state: "applying", applyTransaction: transactionPath, applyTransactionProps: {}, applyActions: [] });
1238 :
1239 12 : return PK.watchTransaction(transactionPath,
1240 12 : {
1241 6 : ErrorCode: (code, details) => this.state.errorMessages.push(details),
1242 :
1243 12 : Finished: exit => {
1244 12 : this.setState({ applyTransaction: null, applyTransactionProps: {}, applyActions: [] });
1245 :
1246 10 : if (exit === PK.Enum.EXIT_SUCCESS) {
1247 10 : this.setState({ state: "loading", loadPercent: null });
1248 10 : this.loadHistory().then(() => {
1249 9 : if (this.state.checkRestartAvailable) {
1250 9 : this.checkNeedsRestart()
1251 9 : .finally(() => this.setState({ state: "updateSuccess" }));
1252 1 : } else {
1253 2 : this.setState({ state: "updateSuccess", loadPercent: null });
1254 2 : }
1255 10 : });
1256 4 : } else if (exit === PK.Enum.EXIT_CANCELLED) {
1257 4 : if (this.state.checkRestartAvailable) {
1258 4 : this.setState({ state: "loading", loadPercent: null });
1259 4 : this.checkNeedsRestart();
1260 4 : }
1261 4 : this.loadUpdates();
1262 1 : } else {
1263 : // normally we get FAILED here with ErrorCodes; handle unexpected errors to allow for some debugging
1264 4 : if (exit !== PK.Enum.EXIT_FAILED)
1265 1 : this.state.errorMessages.push(cockpit.format(_("PackageKit reported error code $0"), exit));
1266 4 : this.setState({ state: "updateError" });
1267 4 : }
1268 12 : },
1269 :
1270 : // not working/being used in at least Fedora
1271 0 : RequireRestart: (type, packageId) => console.log("update RequireRestart", type, packageId),
1272 :
1273 11 : Package: (status, packageId) => this.setState(old =>
1274 11 : ({ applyActions: [...old.applyActions, { status, packageId }] })
1275 11 : ),
1276 12 : },
1277 :
1278 12 : notify => this.setState(old =>
1279 12 : ({ applyTransactionProps: { ...old.applyTransactionProps, ...notify } })
1280 12 : )
1281 12 : )
1282 0 : .catch(ex => {
1283 0 : this.state.errorMessages.push(ex);
1284 0 : this.setState({ state: "updateError" });
1285 0 : });
1286 12 : }
1287 :
1288 11 : applyUpdates(type) {
1289 11 : let updates = [...this.state.updates];
1290 11 : if (type === UPDATES.SECURITY)
1291 1 : updates = updates.filter(update => update.severity === Severity.CRITICAL);
1292 1 : if (type === UPDATES.KPATCHES) {
1293 0 : updates = updates.filter(update => isKpatchPackage(update.name));
1294 1 : }
1295 :
1296 11 : PK.transaction()
1297 11 : .then(transactionPath => {
1298 11 : this.watchUpdates(transactionPath)
1299 11 : .then(() => {
1300 11 : 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 11 : });
1309 11 : })
1310 0 : .catch(ex => {
1311 0 : this.state.errorMessages.push(ex.message);
1312 0 : this.setState({ state: "updateError" });
1313 0 : });
1314 11 : }
1315 :
1316 19 : renderContent() {
1317 19 : let applySecurity;
1318 19 : let applyKpatches;
1319 19 : 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 3 : if (this.state.unregistered && this.state.haveOsRepo === false) {
1326 3 : page_status.set_own({
1327 3 : type: "warning",
1328 3 : title: _("Not registered"),
1329 3 : details: {
1330 3 : link: "subscriptions",
1331 3 : }
1332 3 : });
1333 :
1334 3 : return <EmptyStatePanel
1335 3 : title={_("This system is not registered")}
1336 3 : headingLevel="h5"
1337 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.") }
1338 3 : icon={ExclamationCircleIcon}
1339 3 : action={ _("Register…") }
1340 0 : onAction={ () => cockpit.jump("/subscriptions", cockpit.transport.host) }
1341 3 : />;
1342 3 : }
1343 :
1344 19 : switch (this.state.state) {
1345 19 : case "loading":
1346 19 : case "refreshing":
1347 19 : case "locked":
1348 19 : page_status.set_own({
1349 19 : type: null,
1350 19 : title: _("Checking for package updates..."),
1351 19 : details: {
1352 19 : link: false,
1353 19 : pficon: "spinner",
1354 19 : }
1355 19 : });
1356 :
1357 19 : if (this.state.loadPercent)
1358 12 : return <Progress value={this.state.loadPercent} title={STATE_HEADINGS[this.state.state]} />;
1359 : else
1360 19 : return <EmptyStatePanel loading title={ _("Checking software status")}
1361 19 : headingLevel="h5"
1362 19 : paragraph={STATE_HEADINGS[this.state.state]}
1363 19 : />;
1364 :
1365 16 : case "available":
1366 16 : {
1367 16 : const num_updates = this.state.updates.length;
1368 16 : const num_security_updates = count_security_updates(this.state.updates);
1369 16 : const num_kpatches = count_kpatch_updates(this.state.updates);
1370 16 : const highest_severity = find_highest_severity(this.state.updates);
1371 :
1372 16 : applyAll = (
1373 1 : <Button id={num_updates == num_security_updates ? "install-security" : "install-all"} variant="primary" onClick={ () => this.applyUpdates(UPDATES.ALL) }>
1374 16 : { num_updates == num_security_updates
1375 4 : ? _("Install security updates")
1376 15 : : _("Install all updates") }
1377 16 : </Button>);
1378 :
1379 4 : if (num_security_updates > 0 && num_updates > num_security_updates) {
1380 4 : applySecurity = (
1381 1 : <Button id="install-security" variant="secondary" onClick={ () => this.applyUpdates(UPDATES.SECURITY) }>
1382 4 : {_("Install security updates")}
1383 4 : </Button>);
1384 4 : }
1385 :
1386 3 : if (num_kpatches > 0) {
1387 3 : applyKpatches = (
1388 0 : <Button id="install-kpatches" variant="secondary" onClick={ () => this.applyUpdates(UPDATES.KPATCHES) }>
1389 3 : {_("Install kpatch updates")}
1390 3 : </Button>);
1391 3 : }
1392 :
1393 16 : let text;
1394 16 : if (highest_severity == Severity.CRITICAL)
1395 4 : text = _("Security updates available");
1396 15 : else if (highest_severity >= Severity.IMPORTANT)
1397 3 : text = _("Bug fix updates available");
1398 3 : else if (highest_severity >= Severity.LOW)
1399 3 : text = _("Enhancement updates available");
1400 : else
1401 3 : text = _("Updates available");
1402 :
1403 16 : page_status.set_own({
1404 4 : type: num_security_updates > 0 ? "warning" : "info",
1405 16 : title: text,
1406 16 : details: {
1407 16 : pficon: getPageStatusSeverityIcon(highest_severity)
1408 16 : }
1409 16 : });
1410 :
1411 16 : return (
1412 16 : <>
1413 16 : <PageSection hasBodyWrapper={false}>
1414 16 : <Gallery className='ct-cards-grid' hasGutter>
1415 16 : <CardsPage handleRefresh={this.handleRefresh}
1416 16 : applySecurity={applySecurity}
1417 16 : applyAll={applyAll}
1418 16 : applyKpatches={applyKpatches}
1419 16 : highestSeverity={highest_severity}
1420 16 : onValueChanged={this.onValueChanged}
1421 16 : {...this.state} />
1422 16 : </Gallery>
1423 16 : </PageSection>
1424 16 : { this.state.showRestartServicesDialog &&
1425 3 : <RestartServices
1426 3 : restartPackages={this.state.restartPackages}
1427 0 : close={() => this.setState({ showRestartServicesDialog: false })}
1428 3 : state={this.state.state}
1429 3 : checkNeedsRestart={this.checkNeedsRestart}
1430 0 : onValueChanged={delta => this.setState(delta)}
1431 3 : loadUpdates={this.loadUpdates} />
1432 : }
1433 16 : { this.state.showRebootSystemDialog &&
1434 0 : <ShutdownModal onClose={() => this.setState({ showRebootSystemDialog: false })} />
1435 : }
1436 16 : </>
1437 : );
1438 16 : }
1439 :
1440 3 : case "loadError":
1441 6 : case "updateError":
1442 6 : page_status.set_own({
1443 6 : type: "error",
1444 6 : title: STATE_HEADINGS[this.state.state],
1445 6 : });
1446 6 : return (
1447 6 : <Stack>
1448 6 : <EmptyStatePanel title={ STATE_HEADINGS[this.state.state] }
1449 6 : icon={ ExclamationCircleIcon }
1450 6 : paragraph={
1451 6 : <Content component={ContentVariants.p}>
1452 6 : {_("Please resolve the issue and reload this page.")}
1453 6 : </Content>
1454 : }
1455 6 : />
1456 6 : <CodeBlock className='pf-v6-u-mx-auto error-log'>
1457 6 : <CodeBlockCode>
1458 6 : {this.state.errorMessages
1459 0 : .filter((m, index) => index == 0 || m != this.state.errorMessages[index - 1])
1460 3 : .map(m => <span key={m}>{m}</span>)}
1461 6 : </CodeBlockCode>
1462 6 : </CodeBlock>
1463 6 : </Stack>
1464 : );
1465 :
1466 13 : case "applying":
1467 13 : page_status.set_own(null);
1468 13 : return <ApplyUpdates transactionProps={this.state.applyTransactionProps}
1469 13 : actions={this.state.applyActions}
1470 3 : onCancel={ () => PK.call(this.state.applyTransaction, PK.transactionInterface, "Cancel", []) }
1471 13 : rebootAfter={this.state.rebootAfterSuccess}
1472 1 : setRebootAfter={ (_event, enabled) => this.setState({ rebootAfterSuccess: enabled }) }
1473 13 : />;
1474 :
1475 11 : case "updateSuccess": {
1476 4 : if (this.state.rebootAfterSuccess) {
1477 4 : this.setState({ state: "restart" });
1478 4 : cockpit.spawn(["shutdown", "--reboot", "now"], { superuser: "require" });
1479 4 : return null;
1480 4 : }
1481 :
1482 10 : let warningTitle;
1483 4 : if (!this.state.checkRestartAvailable) {
1484 4 : warningTitle = _("Reboot recommended");
1485 3 : } else {
1486 9 : if (this.state.restartPackages.reboot.length > 0)
1487 3 : warningTitle = cockpit.ngettext("A package needs a system reboot for the updates to take effect:",
1488 3 : "Some packages need a system reboot for the updates to take effect:",
1489 3 : this.state.restartPackages.reboot.length);
1490 9 : else if (this.state.restartPackages.daemons.length > 0)
1491 4 : warningTitle = cockpit.ngettext("A service needs to be restarted for the updates to take effect:",
1492 4 : "Some services need to be restarted for the updates to take effect:",
1493 4 : this.state.restartPackages.daemons.length);
1494 9 : else if (this.state.restartPackages.manual.length > 0)
1495 3 : warningTitle = _("Some software needs to be restarted manually");
1496 9 : }
1497 :
1498 5 : if (warningTitle) {
1499 5 : page_status.set_own({
1500 5 : type: "warning",
1501 5 : title: warningTitle
1502 5 : });
1503 5 : }
1504 :
1505 10 : return (
1506 10 : <>
1507 10 : <UpdateSuccess onIgnore={this.loadUpdates}
1508 1 : openServiceRestartDialog={() => this.setState({ showRestartServicesDialog: true })}
1509 0 : openRebootDialog={() => this.setState({ showRebootSystemDialog: true })}
1510 10 : restart={this.state.restartPackages.daemons}
1511 10 : manual={this.state.restartPackages.manual}
1512 10 : reboot={this.state.restartPackages.reboot}
1513 10 : checkRestartAvailable={this.state.checkRestartAvailable}
1514 10 : history={this.state.history} />
1515 10 : { this.state.showRebootSystemDialog &&
1516 0 : <ShutdownModal onClose={() => this.setState({ showRebootSystemDialog: false })} />
1517 : }
1518 11 : { this.state.showRestartServicesDialog &&
1519 4 : <RestartServices restartPackages={this.state.restartPackages}
1520 0 : close={() => this.setState({ showRestartServicesDialog: false })}
1521 4 : state={this.state.state}
1522 4 : checkNeedsRestart={this.checkNeedsRestart}
1523 0 : onValueChanged={delta => this.setState(delta)}
1524 4 : loadUpdates={this.loadUpdates} />
1525 : }
1526 11 : </>
1527 : );
1528 11 : }
1529 :
1530 4 : case "restart":
1531 4 : page_status.set_own(null);
1532 4 : return <EmptyStatePanel loading title={ _("Restarting") }
1533 4 : headingLevel="h5"
1534 4 : paragraph={ _("Your server will close the connection soon. You can reconnect after it has restarted.") }
1535 4 : />;
1536 :
1537 10 : case "uptodate":
1538 10 : {
1539 10 : page_status.set_own({
1540 10 : title: STATE_HEADINGS[this.state.state],
1541 10 : details: {
1542 10 : link: false,
1543 10 : pficon: "check",
1544 10 : }
1545 10 : });
1546 :
1547 10 : return (
1548 10 : <PageSection hasBodyWrapper={false}>
1549 10 : <Gallery className='ct-cards-grid' hasGutter>
1550 10 : <CardsPage onValueChanged={this.onValueChanged} handleRefresh={this.handleRefresh} {...this.state} />
1551 10 : </Gallery>
1552 10 : { this.state.showRestartServicesDialog &&
1553 3 : <RestartServices restartPackages={this.state.restartPackages}
1554 1 : close={() => this.setState({ showRestartServicesDialog: false })}
1555 3 : state={this.state.state}
1556 3 : checkNeedsRestart={this.checkNeedsRestart}
1557 1 : onValueChanged={delta => this.setState(delta)}
1558 3 : loadUpdates={this.loadUpdates} />
1559 : }
1560 10 : { this.state.showRebootSystemDialog &&
1561 0 : <ShutdownModal onClose={() => this.setState({ showRebootSystemDialog: false })} />
1562 : }
1563 10 : </PageSection>
1564 : );
1565 10 : }
1566 :
1567 3 : default:
1568 3 : page_status.set_own(null);
1569 3 : return null;
1570 19 : }
1571 19 : }
1572 :
1573 10 : handleRefresh() {
1574 10 : this.setState({ state: "refreshing", loadPercent: null });
1575 10 : this.state.packageManager.refresh(true, data => this.setState({ loadPercent: data.percentage }))
1576 10 : .then(() => {
1577 10 : if (this._mounted === false)
1578 10 : return;
1579 :
1580 10 : this.setState({ timeSinceRefresh: 0 });
1581 10 : this.loadUpdates();
1582 10 : })
1583 10 : .catch(this.handleLoadError);
1584 10 : }
1585 :
1586 19 : render() {
1587 19 : let content = this.renderContent();
1588 19 : if (!["available", "uptodate"].includes(this.state.state))
1589 19 : content = <PageSection hasBodyWrapper={false}>{content}</PageSection>;
1590 :
1591 19 : return (
1592 19 : <WithDialogs>
1593 19 : <Page className="pf-m-no-sidebar">
1594 19 : {content}
1595 19 : </Page>
1596 19 : </WithDialogs>
1597 : );
1598 19 : }
1599 19 : }
1600 :
1601 19 : document.addEventListener("DOMContentLoaded", async () => {
1602 19 : init();
1603 :
1604 19 : try {
1605 19 : await cockpit.init();
1606 3 : } catch (exp) {
1607 : /* Remove this when every beiboot scenario has Cockpit 336 */
1608 3 : if (exp.problem === 'not-supported') {
1609 3 : const os_release = await read_os_release();
1610 3 : cockpit.info.os_release = os_release;
1611 3 : }
1612 3 : }
1613 :
1614 19 : const root = createRoot(document.getElementById('app'));
1615 19 : root.render(<OsUpdates />);
1616 19 : });
|