LCOV - code coverage report
Current view: top level - pkg/sosreport - sosreport.jsx Coverage Total Hit
Test: cockpit Lines: 86.4 % 420 363
Test Date: 2026-08-04 16:34:20

            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 : });
        

Generated by: LCOV version 2.0-1