Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 : import '../lib/patternfly/patternfly-6-cockpit.scss';
7 : import "polyfills";
8 : import 'cockpit-dark-theme'; // once per page
9 :
10 5 : import React, { useState } from "react";
11 5 : import { createRoot } from 'react-dom/client';
12 : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
13 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
14 : import { CodeBlockCode } from "@patternfly/react-core/dist/esm/components/CodeBlock/index.js";
15 : import {
16 : Modal, ModalBody, ModalFooter, ModalHeader
17 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
18 : import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
19 : import { Page, PageSection, } from "@patternfly/react-core/dist/esm/components/Page/index.js";
20 : import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
21 : import { Label, LabelGroup } from "@patternfly/react-core/dist/esm/components/Label/index.js";
22 : import { DropdownItem } from '@patternfly/react-core/dist/esm/components/Dropdown/index.js';
23 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
24 : import { InputGroup } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js";
25 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
26 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
27 : import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
28 :
29 : import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
30 : import { ListingTable } from "cockpit-components-table.jsx";
31 : import { basename as path_basename } from "cockpit-path";
32 :
33 5 : import cockpit from "cockpit";
34 : import { useObject, useEvent } from "hooks";
35 : import { superuser } from "superuser";
36 : import * as python from "python";
37 : import { FsInfoClient } from "cockpit/fsinfo";
38 :
39 : import { SuperuserButton } from "superuser-dialogs";
40 :
41 : import { fmt_to_fragments } from "utils.jsx";
42 : import * as timeformat from "timeformat";
43 : import { WithDialogs, useDialogs } from "dialogs.jsx";
44 : import { FormHelper } from "cockpit-components-form-helper";
45 : import { KebabDropdown } from "cockpit-components-dropdown";
46 :
47 : import get_report_dir_py from "./get_report_dir.py";
48 :
49 : import './sosreport.scss';
50 : import { Title, TitleSizes } from '@patternfly/react-core/dist/esm/components/Title';
51 :
52 5 : const _ = cockpit.gettext;
53 :
54 5 : function sosLister() {
55 5 : const self = {
56 5 : ready: false,
57 5 : problem: null,
58 5 : reports: {}
59 5 : };
60 :
61 5 : cockpit.event_target(self);
62 :
63 4 : function emit_changed() {
64 4 : self.dispatchEvent("changed");
65 4 : }
66 :
67 4 : function parse_report_name(name, date) {
68 4 : const archive_rx = /^(secured-)?sosreport-(.*)\.tar\.[^.]+(\.gpg)?$/;
69 4 : const m = name.match(archive_rx);
70 3 : if (m) {
71 3 : let name = m[2];
72 3 : let obfuscated = false;
73 2 : if (name.endsWith("-obfuscated")) {
74 2 : obfuscated = true;
75 2 : name = name.replace(/-obfuscated$/, "");
76 2 : }
77 :
78 3 : return {
79 3 : name,
80 3 : encrypted: !!m[1],
81 3 : obfuscated,
82 3 : date,
83 3 : };
84 3 : }
85 4 : }
86 :
87 5 : let fsinfo = null;
88 :
89 5 : async function restart() {
90 5 : if (superuser.allowed === null)
91 5 : return;
92 :
93 5 : if (fsinfo)
94 2 : fsinfo.close();
95 5 : self.ready = false;
96 5 : self.problem = null;
97 :
98 4 : const report_dir = (await python.spawn(get_report_dir_py)).trim();
99 :
100 4 : fsinfo = new FsInfoClient(report_dir, ["entries", "mtime", "type"], { superuser: "require" });
101 4 : fsinfo.on("change", state => {
102 4 : if (state.loading)
103 4 : return;
104 0 : if (state.error) { // Should Not Happen™, realistic errors come through close event
105 0 : console.warn("Failed to watch for sosreports:", state.error);
106 0 : self.problem = state.error.message ?? state.error.problem;
107 0 : emit_changed();
108 0 : return;
109 0 : }
110 4 : const entries = state.info?.entries;
111 4 : const reports = { };
112 4 : for (const name in entries) {
113 4 : if (entries[name].type === "reg") {
114 4 : const report = parse_report_name(name, entries[name].mtime);
115 4 : if (report)
116 3 : reports[report_dir + '/' + name] = report;
117 4 : }
118 4 : }
119 4 : self.reports = reports;
120 4 : self.ready = true;
121 4 : emit_changed();
122 4 : });
123 :
124 2 : fsinfo.on("close", ex => {
125 2 : self.problem = ex.problem;
126 2 : self.ready = true;
127 2 : emit_changed();
128 2 : });
129 5 : }
130 :
131 0 : self.close = () => {
132 0 : superuser.removeEventListener("changed", restart);
133 0 : if (fsinfo)
134 0 : fsinfo.close();
135 0 : };
136 :
137 5 : restart();
138 5 : superuser.addEventListener("changed", restart);
139 5 : return self;
140 5 : }
141 :
142 4 : function sosCreate(args, setProgress, setError, setErrorDetail, options) {
143 4 : let output = "";
144 4 : let plugins_count = 0;
145 4 : const progress_regex = /Running ([0-9]+)\/([0-9]+):/; // Only for sos < 3.6
146 4 : const finishing_regex = /Finishing plugins.*\[Running: (.*)\]/;
147 4 : const starting_regex = /Starting ([0-9]+)\/([0-9]+).*\[Running: (.*)\]/;
148 :
149 : // TODO - Use a real API instead of scraping stdout once such an API exists
150 4 : const task = cockpit.spawn(["sos", "report", "--batch"].concat(args),
151 4 : { superuser: "require", err: "out", pty: true, ...options });
152 :
153 3 : task.stream(text => {
154 3 : let p = 0;
155 3 : let m;
156 :
157 3 : output += text;
158 3 : const lines = output.split("\n");
159 3 : for (let i = lines.length - 1; i >= 0; i--) {
160 3 : if ((m = starting_regex.exec(lines[i]))) {
161 3 : plugins_count = parseInt(m[2], 10);
162 3 : p = ((parseInt(m[1], 10) - m[3].split(" ").length) / plugins_count) * 100;
163 3 : break;
164 0 : } else if ((m = finishing_regex.exec(lines[i]))) {
165 0 : if (!plugins_count)
166 0 : p = 100;
167 : else
168 0 : p = ((plugins_count - m[1].split(" ").length) / plugins_count) * 100;
169 0 : break;
170 0 : } else if ((m = progress_regex.exec(lines[i]))) {
171 0 : p = (parseInt(m[1], 10) / parseInt(m[2], 10)) * 100;
172 0 : break;
173 0 : }
174 3 : }
175 :
176 3 : setProgress(p);
177 3 : });
178 :
179 3 : task.catch(error => {
180 : // easier investigation of failures, errors in pty mode may be hard to see
181 3 : if (error.problem !== 'cancelled')
182 2 : console.error("Failed to call sos report:", JSON.stringify(error));
183 2 : setError(error.toString() || _("sos report failed"));
184 3 : setErrorDetail(output);
185 3 : });
186 :
187 4 : return task;
188 4 : }
189 :
190 2 : function sosDownload(path) {
191 2 : const basename = path_basename(path);
192 2 : const query = window.btoa(JSON.stringify({
193 2 : host: cockpit.transport.host,
194 2 : payload: "fsread1",
195 2 : binary: "raw",
196 2 : path,
197 2 : superuser: "require",
198 2 : max_read_size: -1,
199 2 : external: {
200 2 : "content-disposition": 'attachment; filename="' + basename + '"',
201 2 : "content-type": "application/x-xz, application/octet-stream"
202 2 : }
203 2 : }));
204 2 : const prefix = (new URL(cockpit.transport.uri("channel/" + cockpit.transport.csrf_token))).pathname;
205 2 : const url = prefix + '?' + query;
206 2 : return new Promise((resolve, reject) => {
207 : // We download via a hidden iframe to get better control over the error cases
208 2 : const iframe = document.createElement("iframe");
209 2 : iframe.setAttribute("src", url);
210 2 : iframe.setAttribute("hidden", "hidden");
211 0 : iframe.addEventListener("load", () => {
212 0 : const title = iframe.contentDocument.title;
213 0 : if (title) {
214 0 : reject(title);
215 0 : } else {
216 0 : resolve();
217 0 : }
218 0 : });
219 2 : document.body.appendChild(iframe);
220 2 : });
221 2 : }
222 :
223 2 : function sosRemove(path) {
224 : // there are various potential extra files; not all of them are expected to exist,
225 : // the file API tolerates removing nonexisting files
226 2 : const paths = [
227 2 : path,
228 2 : path + ".asc",
229 2 : path + ".gpg",
230 2 : path + ".md5",
231 2 : path + ".sha256",
232 2 : ];
233 2 : return Promise.all(paths.map(p => cockpit.file(p, { superuser: "require" }).replace(null)));
234 2 : }
235 :
236 4 : const SOSDialog = () => {
237 4 : const Dialogs = useDialogs();
238 4 : const [label, setLabel] = useState("");
239 4 : const [passphrase, setPassphrase] = useState("");
240 4 : const [showPassphrase, setShowPassphrase] = useState(false);
241 4 : const [obfuscate, setObfuscate] = useState(false);
242 4 : const [verbose, setVerbose] = useState(false);
243 4 : const [task, setTask] = useState(null);
244 4 : const [progress, setProgress] = useState(null);
245 4 : const [error, setError] = useState(null);
246 4 : const [errorDetail, setErrorDetail] = useState(null);
247 :
248 4 : function run() {
249 4 : setError(null);
250 4 : setProgress(null);
251 :
252 4 : const args = [];
253 4 : const options = {};
254 :
255 3 : if (label) {
256 3 : args.push("--label");
257 3 : args.push(label);
258 3 : }
259 :
260 2 : if (passphrase) {
261 2 : args.push("--encrypt");
262 2 : options.environ = ["SOSENCRYPTPASS=" + passphrase];
263 2 : }
264 :
265 2 : if (obfuscate) {
266 2 : args.push("--clean");
267 2 : }
268 :
269 1 : if (verbose) {
270 1 : args.push("-v");
271 1 : }
272 :
273 0 : const task = sosCreate(args, setProgress, err => { if (err === "cancelled") Dialogs.close(); else setError(err); },
274 4 : setErrorDetail, options);
275 4 : setTask(task);
276 4 : task.then(Dialogs.close);
277 4 : task.finally(() => setTask(null));
278 4 : }
279 :
280 4 : const actions = [];
281 4 : actions.push(<Button key="run" isLoading={!!task} isDisabled={!!task} onClick={run}>
282 4 : {_("Run report")}
283 4 : </Button>);
284 4 : if (task)
285 1 : actions.push(<Button key="stop" variant="secondary" onClick={() => task.close("cancelled")}>
286 4 : {_("Stop report")}
287 4 : </Button>);
288 : else
289 4 : actions.push(<Button key="cancel" variant="link" onClick={Dialogs.close}>
290 4 : {_("Cancel")}
291 4 : </Button>);
292 :
293 4 : return <Modal id="sos-dialog"
294 4 : position="top"
295 4 : variant="medium"
296 4 : isOpen
297 4 : onClose={Dialogs.close}>
298 4 : <ModalHeader title={ _("Run new report") } />
299 4 : <ModalBody>
300 4 : { error
301 2 : ? <>
302 2 : <Alert variant="warning" isInline title={error}>
303 2 : <CodeBlockCode>{errorDetail}</CodeBlockCode>
304 2 : </Alert>
305 2 : <br />
306 2 : </>
307 4 : : null }
308 4 : <p>{ _("SOS reporting collects system information to help with diagnosing problems.") }</p>
309 4 : <p>{ _("This information is stored only on the system.") }</p>
310 4 : <br />
311 4 : <Form isHorizontal>
312 4 : <FormGroup label={_("Report label")}>
313 3 : <TextInput id="sos-dialog-ti-1" value={label} onChange={(_event, value) => setLabel(value)} />
314 4 : </FormGroup>
315 4 : <FormGroup label={_("Encryption passphrase")}>
316 4 : <InputGroup>
317 0 : <TextInput type={showPassphrase ? "text" : "password"} value={passphrase} onChange={(_event, value) => setPassphrase(value)}
318 4 : id="sos-dialog-ti-2" autoComplete="new-password" />
319 0 : <Button variant="control" onClick={() => setShowPassphrase(!showPassphrase)}>
320 0 : { showPassphrase ? <EyeSlashIcon /> : <EyeIcon /> }
321 4 : </Button>
322 4 : </InputGroup>
323 4 : <FormHelper helperText={_("Leave empty to skip encryption")} />
324 4 : </FormGroup>
325 4 : <FormGroup label={_("Options")} hasNoPaddingTop>
326 4 : <Checkbox label={_("Obfuscate network addresses, hostnames, and usernames")}
327 2 : id="sos-dialog-cb-1" isChecked={obfuscate} onChange={(_, o) => setObfuscate(o)} />
328 4 : <Checkbox label={_("Use verbose logging")}
329 1 : id="sos-dialog-cb-2" isChecked={verbose} onChange={(_, v) => setVerbose(v)} />
330 4 : </FormGroup>
331 4 : </Form>
332 4 : </ModalBody>
333 4 : <ModalFooter>
334 4 : {actions}
335 3 : {progress ? <span>{cockpit.format(_("Progress: $0"), progress.toFixed() + "%")}</span> : null}
336 4 : </ModalFooter>
337 4 : </Modal>;
338 4 : };
339 :
340 2 : const SOSRemoveDialog = ({ path }) => {
341 2 : const Dialogs = useDialogs();
342 2 : const [task, setTask] = useState(null);
343 2 : const [error, setError] = useState(null);
344 :
345 2 : function remove() {
346 2 : setError(null);
347 2 : setTask(sosRemove(path)
348 2 : .then(Dialogs.close)
349 0 : .catch(err => {
350 0 : setTask(null);
351 0 : setError(err.toString());
352 0 : }));
353 2 : }
354 :
355 2 : return (
356 2 : <Modal id="sos-remove-dialog"
357 2 : position="top"
358 2 : variant="medium"
359 2 : isOpen
360 2 : onClose={Dialogs.close}>
361 2 : <ModalHeader title={_("Delete report permanently?")} titleIconVariant="warning" />
362 2 : <ModalBody>
363 0 : { error && <><Alert variant="warning" isInline title={error} /><br /></> }
364 2 : <p>{fmt_to_fragments(_("The file $0 will be deleted."), <b>{path}</b>)}</p>
365 2 : </ModalBody>
366 2 : <ModalFooter>
367 2 : <Button key="apply"
368 2 : variant="danger"
369 2 : onClick={remove}
370 2 : isLoading={!!task}
371 2 : isDisabled={!!task}>
372 2 : {_("Delete")}
373 2 : </Button>
374 2 : <Button key="cancel"
375 2 : onClick={Dialogs.close}
376 2 : isDisabled={!!task}
377 2 : variant="link">
378 2 : {_("Cancel")}
379 2 : </Button>
380 2 : </ModalFooter>
381 2 : </Modal>);
382 2 : };
383 :
384 0 : const SOSErrorDialog = ({ error }) => {
385 0 : const Dialogs = useDialogs();
386 :
387 0 : return (
388 0 : <Modal id="sos-error-dialog"
389 0 : position="top"
390 0 : variant="medium"
391 0 : isOpen
392 0 : onClose={Dialogs.close}>
393 0 : <ModalHeader title={ _("Error") } />
394 0 : <ModalBody>
395 0 : <p>{error}</p>
396 0 : </ModalBody>
397 0 : </Modal>);
398 0 : };
399 :
400 5 : const SOSBody = () => {
401 5 : const Dialogs = useDialogs();
402 0 : const lister = useObject(sosLister, obj => obj.close, []);
403 5 : useEvent(lister, "changed");
404 :
405 5 : const superuser_proxy = useObject(() => cockpit.dbus(null, { bus: "internal" }).proxy("cockpit.Superuser",
406 5 : "/superuser"),
407 0 : obj => obj.close(),
408 5 : []);
409 5 : useEvent(superuser_proxy, "changed");
410 :
411 5 : if (!lister.ready)
412 5 : return <EmptyStatePanel loading />;
413 :
414 2 : if (lister.problem) {
415 2 : if (lister.problem === "access-denied")
416 2 : return (
417 2 : <EmptyStatePanel
418 2 : title={_("Administrative access required")}
419 2 : paragraph={_("Administrative access is required to create and access reports.")}
420 0 : action={<SuperuserButton />} />);
421 : else
422 0 : return <EmptyStatePanel title={lister.problem} />;
423 2 : }
424 :
425 4 : function run_report() {
426 4 : Dialogs.show(<SOSDialog />);
427 4 : }
428 :
429 3 : function make_report_row(path) {
430 3 : const report = lister.reports[path];
431 :
432 2 : function download() {
433 0 : sosDownload(path).catch(err => Dialogs.show(<SOSErrorDialog error={err.toString()} />));
434 2 : }
435 :
436 2 : function remove() {
437 2 : Dialogs.show(<SOSRemoveDialog path={path} />);
438 2 : }
439 :
440 3 : const labels = [];
441 3 : if (report.encrypted)
442 2 : labels.push(<Label key="enc" color="orange">
443 2 : {_("Encrypted")}
444 2 : </Label>);
445 3 : if (report.obfuscated)
446 2 : labels.push(<Label key="obf" color="grey">
447 2 : {_("Obfuscated")}
448 2 : </Label>);
449 :
450 3 : const action = (
451 3 : <Button variant="secondary" className="show-only-when-wide"
452 3 : onClick={download}>
453 3 : {_("Download")}
454 3 : </Button>);
455 3 : const menu = <KebabDropdown dropdownItems={[
456 3 : <DropdownItem key="download"
457 3 : className="show-only-when-narrow"
458 3 : onClick={download}>
459 3 : {_("Download")}
460 3 : </DropdownItem>,
461 3 : <DropdownItem key="remove"
462 3 : onClick={remove}>
463 3 : {_("Delete")}
464 3 : </DropdownItem>
465 3 : ]} />;
466 :
467 3 : return {
468 3 : props: { key: path },
469 3 : columns: [
470 3 : report.name,
471 3 : timeformat.distanceToNow(new Date(report.date * 1000)),
472 3 : { title: <LabelGroup>{labels}</LabelGroup> },
473 3 : {
474 3 : title: <>{action}{menu}</>,
475 3 : props: { className: "pf-v6-c-table__action table-row-action" }
476 3 : },
477 3 : ]
478 3 : };
479 3 : }
480 :
481 4 : return (
482 4 : <PageSection hasBodyWrapper={false}>
483 4 : <Card isPlain className="ct-card">
484 4 : <CardHeader actions={{
485 4 : actions: <Button id="create-button" variant="primary" onClick={run_report}>
486 4 : {_("Run report")}
487 4 : </Button>,
488 4 : }}>
489 4 : <CardTitle component="h2">{_("Reports")}</CardTitle>
490 4 : </CardHeader>
491 4 : <CardBody className="contains-list">
492 4 : <ListingTable emptyCaption={_("No system reports.")}
493 4 : columns={ [
494 4 : { title: _("Report") },
495 4 : { title: _("Created") },
496 4 : { title: _("Attributes") },
497 4 : ] }
498 4 : rows={Object
499 4 : .keys(lister.reports)
500 0 : .sort((a, b) => lister.reports[b].date - lister.reports[a].date)
501 4 : .map(make_report_row)} />
502 4 : </CardBody>
503 4 : </Card>
504 4 : </PageSection>);
505 5 : };
506 :
507 5 : const SOSPage = () => {
508 5 : return (
509 5 : <WithDialogs>
510 5 : <Page className="pf-m-no-sidebar">
511 5 : <PageSection hasBodyWrapper={false} padding={{ default: "padding" }}>
512 5 : <Flex alignItems={{ default: 'alignItemsCenter' }}>
513 5 : <Title headingLevel="h2" size={TitleSizes['3xl']}>
514 5 : {_("System diagnostics")}
515 5 : </Title>
516 5 : </Flex>
517 5 : </PageSection>
518 5 : <SOSBody />
519 5 : </Page>
520 5 : </WithDialogs>);
521 5 : };
522 :
523 5 : document.addEventListener("DOMContentLoaded", () => {
524 5 : cockpit.translate();
525 5 : const root = createRoot(document.getElementById('app'));
526 5 : root.render(<SOSPage />);
527 5 : });
|