Line data Source code
1 : /*
2 : * Copyright (C) 2017 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 1 : import cockpit from "cockpit";
7 1 : 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 1 : 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 1 : class ImplBase {
33 1 : constructor() {
34 1 : this.supported = true; // false if system was customized in a way that we cannot parse
35 1 : this.enabled = null; // boolean
36 1 : this.type = null; // "all" or "security"
37 1 : this.day = null; // systemd.time(7) day of week (e. g. "mon"), or empty for daily
38 1 : this.time = null; // systemd.time(7) time (e. g. "06:00") or empty for "any time"
39 1 : this.installed = null; // boolean
40 1 : this.packageName = null; // name of the package providing automatic updates
41 1 : }
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 1 : parseCalendar(spec) {
55 : // see systemd.time(7); we only support what we write, otherwise we treat it as custom config and "unsupported"
56 1 : const daysOfWeek = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
57 1 : const validTime = /^((|0|1)[0-9]|2[0-3]):[0-5][0-9]$/;
58 :
59 1 : const words = spec.trim().toLowerCase()
60 1 : .split(/\s+/);
61 :
62 : // check if we have a day of week
63 1 : if (daysOfWeek.indexOf(words[0]) >= 0) {
64 1 : this.day = words.shift();
65 1 : } else if (words[0] === '*-*-*') {
66 1 : this.day = ""; // daily with "all matches" date specification
67 1 : words.shift();
68 1 : } else {
69 1 : this.day = ""; // daily without date specification
70 1 : }
71 :
72 : // now there should only be a time left
73 1 : if (words.length == 1 && validTime.test(words[0]))
74 1 : this.time = words[0].replace(/^0+/, "");
75 : else
76 1 : this.supported = false;
77 1 : }
78 1 : }
79 :
80 1 : 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 1 : }
205 :
206 1 : class Dnf5Impl extends ImplBase {
207 1 : async getConfig() {
208 1 : this.packageName = "dnf5-plugin-automatic";
209 1 : this.configFile = "/etc/dnf/dnf5-plugins/automatic.conf";
210 :
211 1 : try {
212 1 : await cockpit.spawn(["rpm", "-q", this.packageName], { err: "ignore" });
213 1 : this.installed = true;
214 1 : } catch (ex) {
215 1 : this.installed = false;
216 1 : debug("dnf5 getConfig: not installed:", ex);
217 1 : return;
218 1 : }
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 1 : try {
224 1 : const output = await cockpit.script(
225 1 : "set -eu;" +
226 1 : "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 1 : " 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 1 : [], { err: "message" });
233 :
234 1 : this.enabled = (output.indexOf("enabled\n") >= 0);
235 1 : 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 1 : const calIdx = output.indexOf("OnCalendar=");
239 1 : if (calIdx >= 0) {
240 1 : this.parseCalendar(output.substring(calIdx).split('\n')[0].split("=")[1]);
241 1 : } else {
242 1 : if (output.indexOf("InactiveSec=1d\n") >= 0)
243 1 : this.day = this.time = "";
244 1 : else if (this.installed)
245 1 : this.supported = false;
246 1 : }
247 :
248 1 : 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 1 : } catch (error) {
250 1 : console.error("dnf5 getConfig failed:", error);
251 1 : this.supported = false;
252 1 : }
253 1 : }
254 :
255 0 : async setConfig(enabled, type, day, time) {
256 0 : const timerConfD = "/etc/systemd/system/dnf5-automatic.timer.d";
257 0 : const timerConf = timerConfD + "/time.conf";
258 0 : 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 0 : const settings = [];
262 :
263 0 : 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 0 : if (day == null)
272 0 : day = this.day;
273 0 : if (time == null)
274 0 : time = this.time;
275 0 : script += "mkdir -p " + timerConfD + "; ";
276 0 : script += "printf '[Timer]\\nOnBootSec=\\nOnCalendar=" + day + " " + time + "\\n' > " + timerConf + "; ";
277 0 : script += "systemctl daemon-reload; ";
278 0 : }
279 0 : }
280 :
281 0 : if (enabled !== null) {
282 0 : script += "systemctl " + (enabled ? "enable" : "disable") + " --now dnf5-automatic.timer; ";
283 :
284 0 : if (enabled) {
285 0 : settings.push(["apply_updates", "yes"]);
286 0 : settings.push(["reboot", "when-needed"]);
287 0 : }
288 0 : }
289 :
290 0 : debug(`dnf5 setConfig(${enabled}, "${type}", "${day}", "${time}"): script "${script}", settings ${settings}`);
291 :
292 0 : try {
293 0 : if (settings.length > 0) {
294 : // parse/update automatic.conf with the new settings; modify existing or append
295 0 : await cockpit.file(this.configFile, { superuser: "require" }).modify(content => {
296 0 : const lines = content ? content.split('\n') : [];
297 0 : settings.forEach(([key, value]) => {
298 0 : const idx = lines.findIndex(line => line.startsWith(key));
299 0 : if (idx >= 0)
300 0 : lines[idx] = key + " = " + value;
301 : else
302 : // let's avoid context sensitive parsing/writing; multiple sections are ok
303 0 : lines.push(`[commands]\n${key} = ${value}`);
304 0 : });
305 0 : return lines.join('\n');
306 0 : });
307 0 : }
308 :
309 0 : await cockpit.script(script, [], { superuser: "require" });
310 0 : debug("dnf5 setConfig: configuration updated successfully");
311 0 : if (enabled !== null)
312 0 : this.enabled = enabled;
313 0 : if (type !== null)
314 0 : this.type = type;
315 0 : if (day !== null)
316 0 : this.day = day;
317 0 : if (time !== null)
318 0 : this.time = time;
319 0 : } catch (error) {
320 0 : console.error("dnf5 setConfig failed:", error.toString());
321 0 : }
322 0 : }
323 1 : }
324 :
325 : // Returns a promise for instantiating "backend"; this will never fail, if
326 : // automatic updates are not supported, backend will be null.
327 1 : export function getBackend(packagekit_backend, forceReinit) {
328 1 : if (!getBackend.promise || forceReinit) {
329 1 : debug("getBackend() called first time or forceReinit passed, initializing promise");
330 1 : getBackend.promise = new Promise((resolve, reject) => {
331 1 : if (packagekit_backend === "dnf5") {
332 1 : const backend = new Dnf5Impl();
333 1 : backend.getConfig().then(() => resolve(backend));
334 1 : } 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 1 : 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 1 : } else {
346 : // TODO: apt backend
347 1 : resolve(null);
348 1 : }
349 1 : });
350 1 : }
351 1 : return getBackend.promise;
352 1 : }
353 :
354 0 : const AutoUpdatesDialog = ({ backend }) => {
355 0 : const Dialogs = useDialogs();
356 0 : const [pending, setPending] = useState(false);
357 0 : const [enabled, setEnabled] = useState(backend.enabled);
358 0 : const [type, setType] = useState(backend.type);
359 0 : const [day, setDay] = useState(backend.day);
360 0 : const [time, setTime] = useState(backend.time && backend.time.padStart(5, "0"));
361 :
362 0 : function save(event) {
363 0 : setPending(true);
364 0 : backend.setConfig(enabled, type, day, time)
365 0 : .finally(Dialogs.close);
366 :
367 0 : if (event)
368 0 : event.preventDefault();
369 0 : return false;
370 0 : }
371 :
372 0 : return (
373 0 : <Modal position="top" variant="small" id="automatic-updates-dialog" isOpen
374 0 : onClose={Dialogs.close}>
375 0 : <ModalHeader title={_("Automatic updates")} />
376 0 : <ModalBody>
377 0 : <Form isHorizontal onSubmit={save}>
378 0 : <FormGroup fieldId="type" label={_("Type")} hasNoPaddingTop>
379 0 : <Radio isChecked={!enabled}
380 0 : onChange={() => { setEnabled(false); setType(null) }}
381 0 : isDisabled={pending}
382 0 : label={_("No updates")}
383 0 : id="no-updates"
384 0 : name="type" />
385 0 : <Radio isChecked={enabled && type === "security"}
386 0 : onChange={() => { setEnabled(true); setType("security") }}
387 0 : isDisabled={pending}
388 0 : label={_("Security updates only")}
389 0 : id="security-updates"
390 0 : name="type" />
391 0 : <Radio isChecked={enabled && type === "all"}
392 0 : onChange={() => { setEnabled(true); setType("all") }}
393 0 : isDisabled={pending}
394 0 : label={_("All updates")}
395 0 : id="all-updates"
396 0 : name="type" />
397 0 : </FormGroup>
398 :
399 0 : {enabled &&
400 0 : <>
401 0 : <FormGroup fieldId="when" label={_("When")}>
402 0 : <Flex className="auto-update-group">
403 0 : <FormSelect id="auto-update-day"
404 0 : isDisabled={pending}
405 0 : value={day == "" ? "everyday" : day}
406 0 : onChange={(_, d) => setDay(d == "everyday" ? "" : d) }>
407 0 : <FormSelectOption value="everyday" label={_("every day")} />
408 0 : <FormSelectOption value="mon" label={_("Mondays")} />
409 0 : <FormSelectOption value="tue" label={_("Tuesdays")} />
410 0 : <FormSelectOption value="wed" label={_("Wednesdays")} />
411 0 : <FormSelectOption value="thu" label={_("Thursdays")} />
412 0 : <FormSelectOption value="fri" label={_("Fridays")} />
413 0 : <FormSelectOption value="sat" label={_("Saturdays")} />
414 0 : <FormSelectOption value="sun" label={_("Sundays")} />
415 0 : </FormSelect>
416 :
417 0 : <span className="auto-conf-text">{_("at")}</span>
418 :
419 0 : <TimePicker time={time} is24Hour
420 0 : menuAppendTo={() => document.body}
421 0 : id="auto-update-time" isDisabled={pending}
422 0 : invalidFormatErrorMessage={_("Invalid time format")}
423 0 : onChange={(_, time) => setTime(time)} />
424 0 : </Flex>
425 0 : </FormGroup>
426 :
427 0 : <Alert variant="info" title={_("This host will reboot after updates are installed.")} isInline />
428 0 : </>}
429 0 : </Form>
430 0 : </ModalBody>
431 0 : <ModalFooter>
432 0 : <Button variant="primary"
433 0 : isLoading={pending}
434 0 : isDisabled={pending}
435 0 : onClick={save}>
436 0 : {_("Save changes")}
437 0 : </Button>
438 0 : <Button variant="link"
439 0 : isDisabled={pending}
440 0 : onClick={Dialogs.close}>
441 0 : {_("Cancel")}
442 0 : </Button>
443 0 : </ModalFooter>
444 0 : </Modal>);
445 0 : };
446 :
447 1 : export const AutoUpdates = ({ privileged, packagekit_backend, initial_backend }) => {
448 1 : const Dialogs = useDialogs();
449 1 : const [backend, setBackend] = useState(initial_backend);
450 :
451 1 : if (!backend)
452 1 : return null;
453 :
454 1 : let state = null;
455 1 : if (!backend.enabled)
456 1 : state = _("Disabled");
457 1 : if (!backend.installed)
458 1 : state = _("Not set up");
459 :
460 1 : const days = {
461 1 : "": _("every day"),
462 1 : mon: _("every Monday"),
463 1 : tue: _("every Tuesday"),
464 1 : wed: _("every Wednesday"),
465 1 : thu: _("every Thursday"),
466 1 : fri: _("every Friday"),
467 1 : sat: _("every Saturday"),
468 1 : sun: _("every Sunday")
469 1 : };
470 :
471 1 : let desc = null;
472 :
473 1 : if (backend.enabled && backend.supported) {
474 1 : const day = days[backend.day];
475 1 : const time = backend.time;
476 1 : desc = backend.type == "security"
477 1 : ? cockpit.format(_("Security updates will be applied $0 at $1"), day, time)
478 1 : : cockpit.format(_("Updates will be applied $0 at $1"), day, time);
479 1 : }
480 :
481 1 : if (privileged && backend.installed && !backend.supported)
482 1 : return (
483 1 : <div id="autoupdates-settings">
484 1 : <Alert isInline
485 1 : variant="info"
486 1 : className="autoupdates-card-error"
487 1 : title={_("Failed to parse unit files for dnf-automatic.timer or dnf-automatic-install.timer. Please remove custom overrides to configure automatic updates.")} />
488 1 : </div>
489 : );
490 :
491 1 : return (
492 1 : <div id="autoupdates-settings">
493 1 : <Flex alignItems={{ default: 'alignItemsCenter' }}>
494 1 : <Flex grow={{ default: 'grow' }} alignItems={{ default: 'alignItemsBaseline' }}>
495 1 : <FlexItem>
496 1 : <b>{_("Automatic updates")}</b>
497 1 : </FlexItem>
498 1 : <FlexItem>
499 1 : {state}
500 1 : </FlexItem>
501 1 : </Flex>
502 1 : <Flex>
503 1 : <Button variant="secondary"
504 1 : isDisabled={!privileged}
505 1 : size="sm"
506 0 : onClick={() => {
507 0 : if (!backend.installed) {
508 0 : install_dialog(backend.packageName)
509 0 : .then(() => {
510 0 : getBackend(packagekit_backend, true).then(b => {
511 0 : setBackend(b);
512 0 : Dialogs.show(<AutoUpdatesDialog backend={b} />);
513 0 : });
514 0 : }, () => null);
515 0 : } else {
516 0 : Dialogs.show(<AutoUpdatesDialog backend={backend} />);
517 0 : }
518 0 : }}>
519 1 : {!backend.installed ? _("Enable") : _("Edit")}
520 1 : </Button>
521 1 : </Flex>
522 1 : </Flex>
523 1 : {desc}
524 1 : </div>);
525 1 : };
|