Line data Source code
1 : /*
2 : * Copyright (C) 2017 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 19 : import cockpit from "cockpit";
7 19 : import React, { useState } from "react";
8 : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
9 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
10 : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
11 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
12 : import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
13 : import {
14 : Modal, ModalBody, ModalFooter, ModalHeader
15 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
16 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js";
17 : import { TimePicker } from "@patternfly/react-core/dist/esm/components/TimePicker/index.js";
18 :
19 : import { install_dialog } from "cockpit-components-install-dialog.jsx";
20 : import { useDialogs } from "dialogs.jsx";
21 :
22 : import { debug } from "./utils";
23 :
24 19 : const _ = cockpit.gettext;
25 :
26 : /**
27 : * Package manager specific implementations; PackageKit does not cover
28 : * automatic updates, so we have to implement dnf-automatic and
29 : * unattended-upgrades configuration ourselves
30 : */
31 :
32 19 : class ImplBase {
33 19 : constructor() {
34 19 : this.supported = true; // false if system was customized in a way that we cannot parse
35 19 : this.enabled = null; // boolean
36 19 : this.type = null; // "all" or "security"
37 19 : this.day = null; // systemd.time(7) day of week (e. g. "mon"), or empty for daily
38 19 : this.time = null; // systemd.time(7) time (e. g. "06:00") or empty for "any time"
39 19 : this.installed = null; // boolean
40 19 : this.packageName = null; // name of the package providing automatic updates
41 19 : }
42 :
43 : // Init data members. Return a promise that resolves when done.
44 0 : async getConfig() {
45 0 : throw new Error("abstract method");
46 0 : }
47 :
48 : // Update configuration for given non-null values, and update member variables on success;
49 : // return a promise that resolves when done, or fails when configuration writing fails
50 0 : async setConfig(enabled, type, day, time) {
51 0 : throw new Error("abstract method", enabled, type, day, time);
52 0 : }
53 :
54 16 : parseCalendar(spec) {
55 : // see systemd.time(7); we only support what we write, otherwise we treat it as custom config and "unsupported"
56 16 : const daysOfWeek = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
57 16 : const validTime = /^((|0|1)[0-9]|2[0-3]):[0-5][0-9]$/;
58 :
59 16 : const words = spec.trim().toLowerCase()
60 16 : .split(/\s+/);
61 :
62 : // check if we have a day of week
63 3 : if (daysOfWeek.indexOf(words[0]) >= 0) {
64 3 : this.day = words.shift();
65 3 : } else if (words[0] === '*-*-*') {
66 16 : this.day = ""; // daily with "all matches" date specification
67 16 : words.shift();
68 3 : } else {
69 3 : this.day = ""; // daily without date specification
70 3 : }
71 :
72 : // now there should only be a time left
73 16 : if (words.length == 1 && validTime.test(words[0]))
74 3 : this.time = words[0].replace(/^0+/, "");
75 : else
76 3 : this.supported = false;
77 16 : }
78 19 : }
79 :
80 19 : class Dnf4Impl extends ImplBase {
81 0 : async getConfig() {
82 0 : this.packageName = "dnf-automatic";
83 :
84 0 : try {
85 : // - dnf 4 has two ways to enable automatic updates: Either by enabling dnf-automatic-install.timer
86 : // or by setting "apply_updates = yes" in the config file and enabling dnf-automatic.timer
87 : // - the config file determines whether to apply security updates only
88 : // - by default this runs every day (OnUnitInactiveSec=1d), but the timer can be changed with a timer unit
89 : // drop-in, so get the last line
90 0 : const output = await cockpit.script(
91 0 : "set -e; if rpm -q " + this.packageName + " >/dev/null; then echo installed; fi; " +
92 : "if grep -q '^[ \\t]*upgrade_type[ \\t]*=[ \\t]*security' /etc/dnf/automatic.conf; then echo security; fi; " +
93 : "TIMER=dnf-automatic-install.timer; " +
94 : "if systemctl --quiet is-enabled dnf-automatic-install.timer 2>/dev/null; then echo enabled; " +
95 : "elif systemctl --quiet is-enabled dnf-automatic.timer 2>/dev/null && grep -q '^[ \t]*apply_updates[ \t]*=[ \t]*yes' " +
96 : " /etc/dnf/automatic.conf; then echo enabled; TIMER=dnf-automatic.timer; " +
97 : "fi; " +
98 : 'OUT=$(systemctl cat $TIMER 2>/dev/null || true); ' +
99 : 'echo "$OUT" | grep "^OnUnitInactiveSec= *[^ ]" | tail -n1; ' +
100 : 'echo "$OUT" | grep "^OnCalendar= *[^ ]" | tail -n1; ',
101 0 : [], { err: "message" });
102 :
103 0 : this.installed = (output.indexOf("installed\n") >= 0);
104 0 : this.enabled = (output.indexOf("enabled\n") >= 0);
105 0 : this.type = (output.indexOf("security\n") >= 0) ? "security" : "all";
106 :
107 : // if we have OnCalendar=, use that (we disable OnUnitInactiveSec= in our drop-in)
108 0 : const calIdx = output.indexOf("OnCalendar=");
109 0 : if (calIdx >= 0) {
110 0 : this.parseCalendar(output.substring(calIdx).split('\n')[0].split("=")[1]);
111 0 : } else {
112 0 : if (output.indexOf("InactiveSec=1d\n") >= 0)
113 0 : this.day = this.time = "";
114 0 : else if (this.installed)
115 0 : this.supported = false;
116 0 : }
117 :
118 0 : debug(`dnf4 getConfig: supported ${this.supported}, enabled ${this.enabled}, type ${this.type}, day ${this.day}, time ${this.time}, installed ${this.installed}; raw response '${output}'`);
119 0 : } catch (error) {
120 0 : console.error("dnf4 getConfig failed:", error);
121 0 : this.supported = false;
122 0 : }
123 0 : }
124 :
125 0 : async setConfig(enabled, type, day, time) {
126 0 : const timerConf = "/etc/systemd/system/dnf-automatic-install.timer.d/time.conf";
127 0 : let script = "set -e; ";
128 :
129 0 : if (type !== null) {
130 0 : const value = (type == "security") ? "security" : "default";
131 :
132 : // normally upgrade_type = should already be in the file, so replace that line;
133 : // if it's not already present, append it to the file
134 0 : script += "sed -i '/\\bupgrade_type\\b[ \\t]*=/ { h; s/^.*$/upgrade_type = " + value + "/ }; " +
135 0 : "$ { x; /^$/ { s//upgrade_type = " + value + "/; H }; x }' /etc/dnf/automatic.conf; ";
136 0 : }
137 :
138 : // if we enable through Cockpit, make sure that starting the timer doesn't start the .service right away,
139 : // due to the packaged default OnBootSec=1h; just set a reasonable initial time which will trigger the code below
140 0 : if (enabled && !this.enabled && !this.time && !this.day)
141 0 : time = "6:00";
142 :
143 0 : if (time !== null || day !== null) {
144 0 : if (day === "" && time === "") {
145 : // restore defaults
146 0 : script += "rm -f " + timerConf + "; ";
147 0 : } else {
148 0 : if (day == null)
149 0 : day = this.day;
150 0 : if (time == null)
151 0 : time = this.time;
152 0 : script += "mkdir -p /etc/systemd/system/dnf-automatic-install.timer.d; ";
153 0 : script += "printf '[Timer]\\nOnBootSec=\\nOnCalendar=" + day + " " + time + "\\n' > " + timerConf + "; ";
154 0 : script += "systemctl daemon-reload; ";
155 0 : }
156 0 : }
157 :
158 0 : if (enabled !== null) {
159 0 : const rebootConf = "/etc/systemd/system/dnf-automatic-install.service.d/autoreboot.conf";
160 :
161 0 : script += "systemctl " + (enabled ? "enable" : "disable") + " --now dnf-automatic-install.timer; ";
162 :
163 0 : if (enabled) {
164 : /* dnf 4.15+ supports automatic reboots; check if the config option exists, and if so, change the
165 : default to "when-needed"; but be strict about the format, to avoid changing a customized setting */
166 0 : script += "if grep '^[[:space:]]*reboot\\b' /etc/dnf/automatic.conf; then ";
167 0 : script += " sed -i 's/^reboot = never$/reboot = when-needed/' /etc/dnf/automatic.conf; ";
168 : // and drop the previous hack on upgrades */
169 0 : script += " rm -f " + rebootConf + "; ";
170 : /* HACK for older dnf: enable automatic reboots after updating; dnf-automatic does not leave a log
171 : file behind for deciding whether it actually installed anything, so resort to grepping the journal
172 : for the last run (https://bugzilla.redhat.com/show_bug.cgi?id=1491190) */
173 0 : script += "else ";
174 0 : script += " mkdir -p /etc/systemd/system/dnf-automatic-install.service.d; ";
175 0 : script += " printf '[Service]\\nExecStartPost=/bin/sh -ec \"" +
176 : "if systemctl status --no-pager --lines=100 dnf-automatic-install.service| grep -q ===========$$; then " +
177 0 : "shutdown -r +5 rebooting after applying package updates; fi\"\\n' > " + rebootConf + "; ";
178 0 : script += " systemctl daemon-reload; ";
179 0 : script += "fi";
180 0 : } else {
181 : // also make sure that the legacy unit name is disabled; this can fail if the unit does not exist
182 0 : script += "systemctl disable --now dnf-automatic.timer 2>/dev/null || true; ";
183 0 : script += "rm -f " + rebootConf + "; ";
184 0 : }
185 0 : }
186 :
187 0 : debug(`dnf4 setConfig(${enabled}, "${type}", "${day}", "${time}"): script "${script}"`);
188 :
189 0 : try {
190 0 : await cockpit.script(script, [], { superuser: "require" });
191 0 : debug("dnf4 setConfig: configuration updated successfully");
192 0 : if (enabled !== null)
193 0 : this.enabled = enabled;
194 0 : if (type !== null)
195 0 : this.type = type;
196 0 : if (day !== null)
197 0 : this.day = day;
198 0 : if (time !== null)
199 0 : this.time = time;
200 0 : } catch (error) {
201 0 : console.error("dnf4 setConfig failed:", error.toString());
202 0 : }
203 0 : }
204 19 : }
205 :
206 19 : class Dnf5Impl extends ImplBase {
207 19 : async getConfig() {
208 19 : this.packageName = "dnf5-plugin-automatic";
209 19 : this.configFile = "/etc/dnf/dnf5-plugins/automatic.conf";
210 :
211 19 : try {
212 19 : await cockpit.spawn(["rpm", "-q", this.packageName], { err: "ignore" });
213 17 : this.installed = true;
214 4 : } catch (ex) {
215 5 : this.installed = false;
216 5 : debug("dnf5 getConfig: not installed:", ex);
217 5 : return;
218 5 : }
219 :
220 : // - dnf 5 only has a single timer dnf5-automatic.timer and a config file with
221 : // "apply_updates" (yes/no) and "upgrade_type" (default/security)
222 : // - by default this runs every day (OnCalendar)
223 17 : try {
224 17 : const output = await cockpit.script(
225 17 : "set -eu;" +
226 17 : "if grep -q '^[ \\t]*upgrade_type[ \\t]*=[ \\t]*security' " + this.configFile + "; then echo security; fi; " +
227 : "if systemctl --quiet is-enabled dnf5-automatic.timer && " +
228 17 : " grep -q '^[ \t]*apply_updates[ \t]*=[ \t]*yes' " + this.configFile + "; then echo enabled; fi; " +
229 : 'OUT=$(systemctl cat dnf5-automatic.timer || true); ' +
230 : 'echo "$OUT" | grep "^OnUnitInactiveSec= *[^ ]" | tail -n1; ' +
231 : 'echo "$OUT" | grep "^OnCalendar= *[^ ]" | tail -n1; ',
232 17 : [], { err: "message" });
233 :
234 17 : this.enabled = (output.indexOf("enabled\n") >= 0);
235 3 : this.type = (output.indexOf("security\n") >= 0) ? "security" : "all";
236 :
237 : // if we have OnCalendar=, use that (we disable OnUnitInactiveSec= in our drop-in)
238 19 : const calIdx = output.indexOf("OnCalendar=");
239 16 : if (calIdx >= 0) {
240 16 : this.parseCalendar(output.substring(calIdx).split('\n')[0].split("=")[1]);
241 3 : } else {
242 4 : if (output.indexOf("InactiveSec=1d\n") >= 0)
243 3 : this.day = this.time = "";
244 3 : else if (this.installed)
245 3 : this.supported = false;
246 4 : }
247 :
248 17 : debug(`dnf5 getConfig: supported ${this.supported}, enabled ${this.enabled}, type ${this.type}, day ${this.day}, time ${this.time}, installed ${this.installed}; raw response '${output}'`);
249 3 : } catch (error) {
250 3 : console.error("dnf5 getConfig failed:", error);
251 3 : this.supported = false;
252 3 : }
253 19 : }
254 :
255 2 : async setConfig(enabled, type, day, time) {
256 2 : const timerConfD = "/etc/systemd/system/dnf5-automatic.timer.d";
257 2 : const timerConf = timerConfD + "/time.conf";
258 2 : let script = "set -e; ";
259 :
260 : // there's no default config file, admins are supposed to put their own settings into a new file
261 2 : const settings = [];
262 :
263 2 : if (type !== null)
264 0 : settings.push(["upgrade_type", (type == "security") ? "security" : "default"]);
265 :
266 0 : if (time !== null || day !== null) {
267 0 : if (day === "" && time === "") {
268 : // restore defaults
269 0 : script += "rm -f " + timerConf + "; ";
270 0 : } else {
271 2 : if (day == null)
272 0 : day = this.day;
273 2 : if (time == null)
274 0 : time = this.time;
275 2 : script += "mkdir -p " + timerConfD + "; ";
276 2 : script += "printf '[Timer]\\nOnBootSec=\\nOnCalendar=" + day + " " + time + "\\n' > " + timerConf + "; ";
277 2 : script += "systemctl daemon-reload; ";
278 2 : }
279 2 : }
280 :
281 2 : if (enabled !== null) {
282 0 : script += "systemctl " + (enabled ? "enable" : "disable") + " --now dnf5-automatic.timer; ";
283 :
284 2 : if (enabled) {
285 2 : settings.push(["apply_updates", "yes"]);
286 2 : settings.push(["reboot", "when-needed"]);
287 2 : }
288 2 : }
289 :
290 2 : debug(`dnf5 setConfig(${enabled}, "${type}", "${day}", "${time}"): script "${script}", settings ${settings}`);
291 :
292 2 : try {
293 2 : if (settings.length > 0) {
294 : // parse/update automatic.conf with the new settings; modify existing or append
295 2 : await cockpit.file(this.configFile, { superuser: "require" }).modify(content => {
296 0 : const lines = content ? content.split('\n') : [];
297 2 : settings.forEach(([key, value]) => {
298 2 : const idx = lines.findIndex(line => line.startsWith(key));
299 2 : if (idx >= 0)
300 0 : lines[idx] = key + " = " + value;
301 : else
302 : // let's avoid context sensitive parsing/writing; multiple sections are ok
303 2 : lines.push(`[commands]\n${key} = ${value}`);
304 2 : });
305 2 : return lines.join('\n');
306 2 : });
307 2 : }
308 :
309 2 : await cockpit.script(script, [], { superuser: "require" });
310 2 : debug("dnf5 setConfig: configuration updated successfully");
311 2 : if (enabled !== null)
312 2 : this.enabled = enabled;
313 2 : if (type !== null)
314 2 : this.type = type;
315 2 : if (day !== null)
316 2 : this.day = day;
317 2 : if (time !== null)
318 2 : this.time = time;
319 0 : } catch (error) {
320 0 : console.error("dnf5 setConfig failed:", error.toString());
321 0 : }
322 2 : }
323 19 : }
324 :
325 : // Returns a promise for instantiating "backend"; this will never fail, if
326 : // automatic updates are not supported, backend will be null.
327 19 : export function getBackend(packagekit_backend, forceReinit) {
328 11 : if (!getBackend.promise || forceReinit) {
329 19 : debug("getBackend() called first time or forceReinit passed, initializing promise");
330 19 : getBackend.promise = new Promise((resolve, reject) => {
331 19 : if (packagekit_backend === "dnf5") {
332 19 : const backend = new Dnf5Impl();
333 18 : backend.getConfig().then(() => resolve(backend));
334 3 : } else if (packagekit_backend === "dnf") {
335 : // we need to do this runtime check -- you can e.g. install dnf5 on Fedora 40, but it's not the "main" dnf
336 3 : cockpit.spawn(["dnf", "--version"], { err: "message" })
337 0 : .then(version => {
338 0 : const backend = version.includes("dnf5") ? new Dnf5Impl() : new Dnf4Impl();
339 0 : backend.getConfig().then(() => resolve(backend));
340 0 : })
341 0 : .catch(ex => {
342 0 : console.error("failed to run dnf --version:", ex);
343 0 : resolve(null);
344 0 : });
345 3 : } else {
346 : // TODO: apt backend
347 3 : resolve(null);
348 3 : }
349 19 : });
350 19 : }
351 19 : return getBackend.promise;
352 19 : }
353 :
354 3 : const AutoUpdatesDialog = ({ backend }) => {
355 3 : const Dialogs = useDialogs();
356 3 : const [pending, setPending] = useState(false);
357 3 : const [enabled, setEnabled] = useState(backend.enabled);
358 3 : const [type, setType] = useState(backend.type);
359 3 : const [day, setDay] = useState(backend.day);
360 2 : const [time, setTime] = useState(backend.time && backend.time.padStart(5, "0"));
361 :
362 2 : function save(event) {
363 2 : setPending(true);
364 2 : backend.setConfig(enabled, type, day, time)
365 2 : .finally(Dialogs.close);
366 :
367 2 : if (event)
368 2 : event.preventDefault();
369 2 : return false;
370 2 : }
371 :
372 3 : return (
373 3 : <Modal position="top" variant="small" id="automatic-updates-dialog" isOpen
374 3 : onClose={Dialogs.close}>
375 3 : <ModalHeader title={_("Automatic updates")} />
376 3 : <ModalBody>
377 3 : <Form isHorizontal onSubmit={save}>
378 3 : <FormGroup fieldId="type" label={_("Type")} hasNoPaddingTop>
379 3 : <Radio isChecked={!enabled}
380 0 : onChange={() => { setEnabled(false); setType(null) }}
381 3 : isDisabled={pending}
382 3 : label={_("No updates")}
383 3 : id="no-updates"
384 3 : name="type" />
385 2 : <Radio isChecked={enabled && type === "security"}
386 0 : onChange={() => { setEnabled(true); setType("security") }}
387 3 : isDisabled={pending}
388 3 : label={_("Security updates only")}
389 3 : id="security-updates"
390 3 : name="type" />
391 2 : <Radio isChecked={enabled && type === "all"}
392 2 : onChange={() => { setEnabled(true); setType("all") }}
393 3 : isDisabled={pending}
394 3 : label={_("All updates")}
395 3 : id="all-updates"
396 3 : name="type" />
397 3 : </FormGroup>
398 :
399 3 : {enabled &&
400 2 : <>
401 2 : <FormGroup fieldId="when" label={_("When")}>
402 2 : <Flex className="auto-update-group">
403 2 : <FormSelect id="auto-update-day"
404 2 : isDisabled={pending}
405 0 : value={day == "" ? "everyday" : day}
406 0 : onChange={(_, d) => setDay(d == "everyday" ? "" : d) }>
407 2 : <FormSelectOption value="everyday" label={_("every day")} />
408 2 : <FormSelectOption value="mon" label={_("Mondays")} />
409 2 : <FormSelectOption value="tue" label={_("Tuesdays")} />
410 2 : <FormSelectOption value="wed" label={_("Wednesdays")} />
411 2 : <FormSelectOption value="thu" label={_("Thursdays")} />
412 2 : <FormSelectOption value="fri" label={_("Fridays")} />
413 2 : <FormSelectOption value="sat" label={_("Saturdays")} />
414 2 : <FormSelectOption value="sun" label={_("Sundays")} />
415 2 : </FormSelect>
416 :
417 2 : <span className="auto-conf-text">{_("at")}</span>
418 :
419 2 : <TimePicker time={time} is24Hour
420 0 : menuAppendTo={() => document.body}
421 2 : id="auto-update-time" isDisabled={pending}
422 2 : invalidFormatErrorMessage={_("Invalid time format")}
423 0 : onChange={(_, time) => setTime(time)} />
424 2 : </Flex>
425 2 : </FormGroup>
426 :
427 2 : <Alert variant="info" title={_("This host will reboot after updates are installed.")} isInline />
428 2 : </>}
429 3 : </Form>
430 3 : </ModalBody>
431 3 : <ModalFooter>
432 3 : <Button variant="primary"
433 3 : isLoading={pending}
434 3 : isDisabled={pending}
435 3 : onClick={save}>
436 3 : {_("Save changes")}
437 3 : </Button>
438 3 : <Button variant="link"
439 3 : isDisabled={pending}
440 3 : onClick={Dialogs.close}>
441 3 : {_("Cancel")}
442 3 : </Button>
443 3 : </ModalFooter>
444 3 : </Modal>);
445 3 : };
446 :
447 18 : export const AutoUpdates = ({ privileged, packagekit_backend, initial_backend }) => {
448 18 : const Dialogs = useDialogs();
449 18 : const [backend, setBackend] = useState(initial_backend);
450 :
451 18 : if (!backend)
452 3 : return null;
453 :
454 18 : let state = null;
455 18 : if (!backend.enabled)
456 18 : state = _("Disabled");
457 18 : if (!backend.installed)
458 5 : state = _("Not set up");
459 :
460 18 : const days = {
461 18 : "": _("every day"),
462 18 : mon: _("every Monday"),
463 18 : tue: _("every Tuesday"),
464 18 : wed: _("every Wednesday"),
465 18 : thu: _("every Thursday"),
466 18 : fri: _("every Friday"),
467 18 : sat: _("every Saturday"),
468 18 : sun: _("every Sunday")
469 18 : };
470 :
471 18 : let desc = null;
472 :
473 5 : if (backend.enabled && backend.supported) {
474 5 : const day = days[backend.day];
475 5 : const time = backend.time;
476 5 : desc = backend.type == "security"
477 3 : ? cockpit.format(_("Security updates will be applied $0 at $1"), day, time)
478 5 : : cockpit.format(_("Updates will be applied $0 at $1"), day, time);
479 5 : }
480 :
481 17 : if (privileged && backend.installed && !backend.supported)
482 18 : return (
483 3 : <div id="autoupdates-settings">
484 3 : <Alert isInline
485 3 : variant="info"
486 3 : className="autoupdates-card-error"
487 3 : title={_("Failed to parse unit files for dnf-automatic.timer or dnf-automatic-install.timer. Please remove custom overrides to configure automatic updates.")} />
488 3 : </div>
489 : );
490 :
491 18 : return (
492 18 : <div id="autoupdates-settings">
493 18 : <Flex alignItems={{ default: 'alignItemsCenter' }}>
494 18 : <Flex grow={{ default: 'grow' }} alignItems={{ default: 'alignItemsBaseline' }}>
495 18 : <FlexItem>
496 18 : <b>{_("Automatic updates")}</b>
497 18 : </FlexItem>
498 18 : <FlexItem>
499 18 : {state}
500 18 : </FlexItem>
501 18 : </Flex>
502 18 : <Flex>
503 18 : <Button variant="secondary"
504 18 : isDisabled={!privileged}
505 18 : size="sm"
506 4 : onClick={() => {
507 2 : if (!backend.installed) {
508 2 : install_dialog(backend.packageName)
509 1 : .then(() => {
510 1 : getBackend(packagekit_backend, true).then(b => {
511 1 : setBackend(b);
512 1 : Dialogs.show(<AutoUpdatesDialog backend={b} />);
513 1 : });
514 0 : }, () => null);
515 0 : } else {
516 2 : Dialogs.show(<AutoUpdatesDialog backend={backend} />);
517 2 : }
518 4 : }}>
519 4 : {!backend.installed ? _("Enable") : _("Edit")}
520 18 : </Button>
521 18 : </Flex>
522 18 : </Flex>
523 18 : {desc}
524 18 : </div>);
525 18 : };
|