LCOV - code coverage report
Current view: top level - pkg/systemd - logsJournal.jsx Coverage Total Hit
Test: cockpit Lines: 96.2 % 262 252
Test Date: 2026-07-02 14:11:36

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2020 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6            9 : import cockpit from "cockpit";
       7              : import { journal } from "journal";
       8              : import { superuser } from "superuser";
       9              : 
      10            9 : import React from 'react';
      11              : import { Alert, AlertActionCloseButton } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
      12              : import { EmptyStatePanel } from "cockpit-components-empty-state.jsx";
      13              : import { JournalOutput } from "cockpit-components-logs-panel.jsx";
      14              : import { ExclamationCircleIcon } from '@patternfly/react-icons';
      15              : 
      16              : import { getGrepFiltersFromOptions, getFilteredQuery } from "./logsHelpers.js";
      17              : 
      18              : // We open a couple of long-running channels with { superuser: "try" },
      19              : // so we need to reload the page if the access level changes.
      20            9 : superuser.reload_page_on_change();
      21              : 
      22            9 : const _ = cockpit.gettext;
      23              : // Stop stream when entries > QUERY_MORE after clicking 'Load earlier entries' button
      24            9 : const QUERY_MORE = 1000;
      25              : // Stop stream when entries > QUERY_COUNT
      26            9 : const QUERY_COUNT = 5000;
      27              : 
      28            9 : export class JournalBox extends React.Component {
      29            9 :     constructor(props) {
      30            9 :         super(props);
      31            9 :         this.state = {
      32            9 :             cursor: undefined,
      33            9 :             loading: true,
      34            9 :             logs: [],
      35            9 :             streamFinished: false,
      36            9 :             didntReachStart: true,
      37            9 :         };
      38              : 
      39            9 :         this.appendEntries = this.appendEntries.bind(this);
      40            9 :         this.followingProcs = [];
      41            9 :         this.loadServiceFilters = this.loadServiceFilters.bind(this);
      42            9 :         this.prependEntries = this.prependEntries.bind(this);
      43            9 :         this.procs = [];
      44            9 :         this.updateQuery = this.updateQuery.bind(this);
      45            9 :         this.queryError = this.queryError.bind(this);
      46              : 
      47            9 :         this.options = cockpit.location.options;
      48            9 :     }
      49              : 
      50            9 :     componentDidMount() {
      51            9 :         cockpit.addEventListener("locationchanged", this.updateQuery);
      52            9 :         this.updateQuery();
      53            9 :     }
      54              : 
      55            9 :     componentDidUpdate(prevProps) {
      56            2 :         if (prevProps.dataFollowing != this.props.dataFollowing) {
      57            2 :             if (this.props.dataFollowing) {
      58            2 :                 const cursor = document.querySelector(".cockpit-logline");
      59            2 :                 if (cursor)
      60            2 :                     this.follow(cursor.getAttribute("data-cursor"));
      61              :                 else
      62            2 :                     this.follow();
      63            2 :             } else {
      64            2 :                 this.stopFollowing();
      65            2 :             }
      66            2 :         }
      67            9 :     }
      68              : 
      69            9 :     updateQuery() {
      70            9 :         this.stop();
      71              : 
      72            9 :         this.options = cockpit.location.options;
      73            9 :         this.match = getGrepFiltersFromOptions({ options: this.options })[1];
      74            9 :         const { dataFollowing, defaultSince, updateIdentifiersList, setFilteredQuery } = this.props;
      75            9 :         const { priority, grep, boot, since, until } = this.options;
      76            2 :         let last = dataFollowing ? null : 1;
      77            9 :         let count = 0;
      78            9 :         let oldest = null;
      79            7 :         const all = boot === undefined && since === undefined && until === undefined;
      80              : 
      81            9 :         this.out = new JournalOutput(this.options);
      82            9 :         this.renderer = journal.renderer(this.out);
      83              : 
      84            9 :         const tags_match = [];
      85            8 :         this.match.forEach(field => {
      86            8 :             if (!field.startsWith("SYSLOG_IDENTIFIER"))
      87            4 :                 tags_match.push(field);
      88            8 :         });
      89              : 
      90            9 :         const journalctlOptions = {
      91            9 :             boot,
      92            9 :             follow: false, /* follow: Show only the most recent journal entries, and continuously print new entries as they are appended to the journal. */
      93            9 :             grep,
      94            9 :             priority,
      95            9 :             reverse: true, /* reverse: Reverse output so that the newest entries are displayed first */
      96            7 :             since: since || defaultSince,
      97            9 :             until
      98            9 :         };
      99              : 
     100            9 :         setFilteredQuery(getFilteredQuery({ match: this.match, options: journalctlOptions }));
     101              : 
     102            9 :         if (updateIdentifiersList)
     103            9 :             this.loadServiceFilters(tags_match, journalctlOptions);
     104              : 
     105            9 :         this.setState({ loading: true, didntReachStart: false, streamFinished: false, logs: [] });
     106              : 
     107            9 :         const promise = journal.journalctl(this.match, journalctlOptions)
     108            9 :                 .fail(this.queryError)
     109            9 :                 .stream(entries => {
     110            9 :                     if (!last) {
     111            9 :                         last = entries[0].__CURSOR;
     112            9 :                         this.follow(last);
     113            9 :                     }
     114            9 :                     count += entries.length;
     115            9 :                     this.appendEntries(entries);
     116            9 :                     oldest = entries[entries.length - 1].__CURSOR;
     117            1 :                     if (count >= QUERY_COUNT) {
     118            1 :                         this.setState({ didntReachStart: true, cursor: oldest });
     119            1 :                         promise.stop();
     120            1 :                     }
     121            9 :                 })
     122            9 :                 .done(() => {
     123            9 :                     this.setState({ streamFinished: true });
     124              : 
     125            3 :                     if (!last && !promise.stopped) {
     126            3 :                         const journalctlOptions = {
     127            3 :                             boot,
     128            3 :                             count: 0,
     129            3 :                             follow: true,
     130            3 :                             grep,
     131            3 :                             priority,
     132            3 :                             since,
     133            3 :                             until,
     134            3 :                         };
     135            3 :                         this.followingProcs.push(journal.journalctl(this.match, journalctlOptions)
     136            3 :                                 .fail(this.queryError)
     137            2 :                                 .stream(entries => {
     138            2 :                                     this.prependEntries(entries);
     139            2 :                                 }));
     140            3 :                     }
     141            9 :                     if (!all)
     142            7 :                         this.setState({ didntReachStart: true, cursor: oldest });
     143            9 :                 })
     144            9 :                 .always(() => this.setState({ loading: false }));
     145            9 :         this.procs.push(promise);
     146            9 :     }
     147              : 
     148            0 :     queryError(error) {
     149            0 :         this.setState({ error: cockpit.message(error) });
     150            0 :     }
     151              : 
     152            7 :     prependEntries(entries) {
     153            2 :         for (let i = 0; i < entries.length; i++) {
     154            2 :             const serviceTag = entries[i].SYSLOG_IDENTIFIER;
     155            2 :             this.renderer.prepend(entries[i]);
     156              :             // Only update if the service is not yet known
     157            2 :             if (serviceTag && !this.props.currentIdentifiers?.includes(serviceTag))
     158              :                 // Due to asynchronous nature it needs to be checked whether this
     159              :                 // identifier is not yet defined. The previous check could be omitted
     160              :                 // and only this one used but let's try to trigger as few updates as possible
     161            2 :                 this.props.setCurrentIdentifiers(identifiers => {
     162            2 :                     if (!identifiers.includes(serviceTag))
     163            2 :                         return [...identifiers, serviceTag];
     164            1 :                     return identifiers;
     165            2 :                 });
     166            2 :         }
     167            2 :         this.renderer.prepend_flush();
     168              : 
     169            2 :         this.setState({ logs: this.out.logs, loading: false });
     170            7 :     }
     171              : 
     172            9 :     appendEntries(entries) {
     173            9 :         for (let i = 0; i < entries.length; i++)
     174            9 :             this.renderer.append(entries[i]);
     175            9 :         this.renderer.append_flush();
     176              : 
     177            9 :         this.setState({ logs: this.out.logs, loading: false });
     178            9 :     }
     179              : 
     180            9 :     follow(cursor) {
     181            9 :         const { priority, until, grep } = this.options;
     182              : 
     183            9 :         const journalctlOptions = {
     184            9 :             count: 0,
     185            2 :             cursor: cursor || null,
     186            9 :             follow: true,
     187            9 :             grep,
     188            9 :             priority,
     189            9 :             until,
     190            9 :         };
     191            9 :         this.followingProcs.push(journal.journalctl(this.match, journalctlOptions)
     192            9 :                 .fail(this.queryError)
     193            7 :                 .stream(entries => {
     194            7 :                     if (entries[0].__CURSOR == cursor)
     195            7 :                         entries.shift();
     196            7 :                     this.prependEntries(entries);
     197            7 :                 }));
     198            9 :     }
     199              : 
     200            9 :     loadServiceFilters(match, options) {
     201              :         // Ideally this would use `--output cat --output-fields SYSLOG_IDENTIFIER` and do
     202              :         // without `sh -ec`, grep, sort, replaceAll and all of those ugly stuff
     203              :         // For that we however need newer systemd that includes https://github.com/systemd/systemd/issues/13937
     204            9 :         const currentServices = new Set();
     205            9 :         const service_options = Object.assign({ output: "verbose" }, options);
     206            9 :         let cmd = journal.build_cmd(match, service_options);
     207              : 
     208              :         // cribbed from Python's shlex.quote()
     209            9 :         cmd = cmd.map(i => `'` + i.replaceAll(`'`, `'"'"'`) + `'`).join(" ");
     210            9 :         cmd = "set -o pipefail; " + cmd + " | grep SYSLOG_IDENTIFIER= | sort -u";
     211            9 :         cockpit.spawn(["/bin/bash", "-ec", cmd], { superuser: "try", err: "message" })
     212            9 :                 .then(entries => {
     213            9 :                     entries.split("\n").forEach(entry => {
     214            9 :                         if (entry)
     215            9 :                             currentServices.add(entry.substring(entry.indexOf('=') + 1));
     216            9 :                     });
     217            9 :                 })
     218            2 :                 .catch(e => {
     219              :                     // grep returns `1` when nothing to match, but in that case message is empty
     220            2 :                     if (e.message)
     221            2 :                         console.log("Failed to load services:", e.message);
     222            2 :                 })
     223            9 :                 .finally(() => {
     224            9 :                     this.props.setCurrentIdentifiers(Array.from(currentServices));
     225            9 :                 });
     226            9 :     }
     227              : 
     228            9 :     stop() {
     229            9 :         this.procs.forEach(proc => proc.stop());
     230            9 :         this.followingProcs.forEach(proc => proc.stop());
     231            9 :     }
     232              : 
     233            1 :     stopFollowing() {
     234            1 :         this.followingProcs.forEach(proc => proc.stop());
     235            1 :     }
     236              : 
     237            9 :     render() {
     238            9 :         const { priority, grep } = this.options;
     239            9 :         const noLogs = !this.state.logs.length;
     240            9 :         let error = null;
     241            9 :         if (this.state.error)
     242            9 :             error = (
     243            1 :                 <Alert variant="danger"
     244            1 :                        isInline
     245            0 :                        actionClose={<AlertActionCloseButton onClose={() => this.setState({ error: undefined })} />}
     246            1 :                        title={_("Failed to fetch logs")}>
     247            1 :                     {this.state.error}
     248            1 :                 </Alert>
     249              :             );
     250              : 
     251            9 :         if (!this.state.logs.length && this.state.loading)
     252            9 :             return (
     253            9 :                 <>
     254            9 :                     {error}
     255            9 :                     <EmptyStatePanel loading title={_("Loading...")} />
     256            9 :                 </>
     257              :             );
     258              : 
     259              :         /* Journalctl command stream finished and there are not more entries to query */
     260            2 :         if (!this.state.logs.length && !this.state.didntReachStart && this.state.streamFinished) {
     261            2 :             return (
     262            2 :                 <div id="start-box" className="journal-start">
     263            2 :                     {error}
     264            2 :                     <EmptyStatePanel action={_("Clear all filters")}
     265            2 :                                      icon={ExclamationCircleIcon}
     266            2 :                                      actionVariant="link"
     267            0 :                                      onAction={() => cockpit.location.go('/')}
     268            2 :                                      paragraph={_("Can not find any logs using the current combination of filters.")}
     269            2 :                                      title={_("No logs found")}
     270            2 :                     />
     271            2 :                 </div>
     272              :             );
     273            2 :         }
     274            9 :         const loadEarlier = (
     275              :             /* Show 'Load earlier entries' button if we didn't reach start yet */
     276            9 :             this.state.didntReachStart
     277            7 :                 ? <EmptyStatePanel action={_("Load earlier entries")}
     278            3 :                              icon={noLogs ? ExclamationCircleIcon : undefined}
     279            7 :                              isActionInProgress={this.state.loading}
     280            1 :                              onAction={() => {
     281            1 :                                  let count = 0;
     282            1 :                                  this.setState({ loading: true });
     283              : 
     284            1 :                                  const journalctlOptions = {
     285            1 :                                      cursor: this.state.cursor,
     286            1 :                                      follow: false,
     287            1 :                                      grep,
     288            1 :                                      priority,
     289            1 :                                      reverse: true,
     290            1 :                                  };
     291            1 :                                  this.setState({ didntReachStart: false });
     292            1 :                                  const promise = journal.journalctl(this.match, journalctlOptions)
     293            1 :                                          .fail(this.queryError)
     294            1 :                                          .stream(entries => {
     295            1 :                                              if (entries[0].__CURSOR == this.state.cursor)
     296            1 :                                                  entries.shift();
     297            1 :                                              count += entries.length;
     298            1 :                                              this.appendEntries(entries);
     299            0 :                                              if (count >= QUERY_MORE) {
     300            0 :                                                  const stopped = entries[entries.length - 1].__CURSOR;
     301            0 :                                                  this.setState({ didntReachStart: true, cursor: stopped, loading: false });
     302            0 :                                                  promise.stop();
     303            0 :                                              }
     304            1 :                                          })
     305            1 :                                          .done(() => {
     306            1 :                                              this.setState({ streamFinished: true, loading: false });
     307            1 :                                          });
     308            1 :                                  this.procs.push(promise);
     309            1 :                              }}
     310            3 :                              paragraph={noLogs ? _("You may try to load older entries.") : ""}
     311            3 :                              title={noLogs ? _("No logs found") : ""}
     312            7 :                              loading={this.state.loading} />
     313            8 :                 : null
     314              :         );
     315              : 
     316            9 :         return (
     317            9 :             <>
     318            9 :                 {error}
     319            9 :                 {this.state.logs.length
     320            9 :                     ? <div id="journal-logs" className="panel panel-default cockpit-log-panel" role="table">
     321            9 :                         {this.state.logs}
     322            9 :                     </div>
     323            3 :                     : null}
     324            9 :                 <div id="start-box" className="journal-start">
     325            9 :                     {loadEarlier}
     326            9 :                 </div>
     327            9 :             </>
     328              :         );
     329            9 :     }
     330            9 : }
        

Generated by: LCOV version 2.0-1