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-06-25 11:17:56

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2020 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           12 : import cockpit from "cockpit";
       7              : import { journal } from "journal";
       8              : import { superuser } from "superuser";
       9              : 
      10           12 : 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           12 : superuser.reload_page_on_change();
      21              : 
      22           12 : const _ = cockpit.gettext;
      23              : // Stop stream when entries > QUERY_MORE after clicking 'Load earlier entries' button
      24           12 : const QUERY_MORE = 1000;
      25              : // Stop stream when entries > QUERY_COUNT
      26           12 : const QUERY_COUNT = 5000;
      27              : 
      28           12 : export class JournalBox extends React.Component {
      29           12 :     constructor(props) {
      30           12 :         super(props);
      31           12 :         this.state = {
      32           12 :             cursor: undefined,
      33           12 :             loading: true,
      34           12 :             logs: [],
      35           12 :             streamFinished: false,
      36           12 :             didntReachStart: true,
      37           12 :         };
      38              : 
      39           12 :         this.appendEntries = this.appendEntries.bind(this);
      40           12 :         this.followingProcs = [];
      41           12 :         this.loadServiceFilters = this.loadServiceFilters.bind(this);
      42           12 :         this.prependEntries = this.prependEntries.bind(this);
      43           12 :         this.procs = [];
      44           12 :         this.updateQuery = this.updateQuery.bind(this);
      45           12 :         this.queryError = this.queryError.bind(this);
      46              : 
      47           12 :         this.options = cockpit.location.options;
      48           12 :     }
      49              : 
      50           12 :     componentDidMount() {
      51           12 :         cockpit.addEventListener("locationchanged", this.updateQuery);
      52           12 :         this.updateQuery();
      53           12 :     }
      54              : 
      55           12 :     componentDidUpdate(prevProps) {
      56            3 :         if (prevProps.dataFollowing != this.props.dataFollowing) {
      57            3 :             if (this.props.dataFollowing) {
      58            3 :                 const cursor = document.querySelector(".cockpit-logline");
      59            3 :                 if (cursor)
      60            3 :                     this.follow(cursor.getAttribute("data-cursor"));
      61              :                 else
      62            3 :                     this.follow();
      63            3 :             } else {
      64            3 :                 this.stopFollowing();
      65            3 :             }
      66            3 :         }
      67           12 :     }
      68              : 
      69           12 :     updateQuery() {
      70           12 :         this.stop();
      71              : 
      72           12 :         this.options = cockpit.location.options;
      73           12 :         this.match = getGrepFiltersFromOptions({ options: this.options })[1];
      74           12 :         const { dataFollowing, defaultSince, updateIdentifiersList, setFilteredQuery } = this.props;
      75           12 :         const { priority, grep, boot, since, until } = this.options;
      76            3 :         let last = dataFollowing ? null : 1;
      77           12 :         let count = 0;
      78           12 :         let oldest = null;
      79           10 :         const all = boot === undefined && since === undefined && until === undefined;
      80              : 
      81           12 :         this.out = new JournalOutput(this.options);
      82           12 :         this.renderer = journal.renderer(this.out);
      83              : 
      84           12 :         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           12 :         const journalctlOptions = {
      91           12 :             boot,
      92           12 :             follow: false, /* follow: Show only the most recent journal entries, and continuously print new entries as they are appended to the journal. */
      93           12 :             grep,
      94           12 :             priority,
      95           12 :             reverse: true, /* reverse: Reverse output so that the newest entries are displayed first */
      96           10 :             since: since || defaultSince,
      97           12 :             until
      98           12 :         };
      99              : 
     100           12 :         setFilteredQuery(getFilteredQuery({ match: this.match, options: journalctlOptions }));
     101              : 
     102           12 :         if (updateIdentifiersList)
     103           12 :             this.loadServiceFilters(tags_match, journalctlOptions);
     104              : 
     105           12 :         this.setState({ loading: true, didntReachStart: false, streamFinished: false, logs: [] });
     106              : 
     107           12 :         const promise = journal.journalctl(this.match, journalctlOptions)
     108           12 :                 .fail(this.queryError)
     109           11 :                 .stream(entries => {
     110           11 :                     if (!last) {
     111           11 :                         last = entries[0].__CURSOR;
     112           11 :                         this.follow(last);
     113           11 :                     }
     114           11 :                     count += entries.length;
     115           11 :                     this.appendEntries(entries);
     116           11 :                     oldest = entries[entries.length - 1].__CURSOR;
     117            2 :                     if (count >= QUERY_COUNT) {
     118            2 :                         this.setState({ didntReachStart: true, cursor: oldest });
     119            2 :                         promise.stop();
     120            2 :                     }
     121           11 :                 })
     122           11 :                 .done(() => {
     123           11 :                     this.setState({ streamFinished: true });
     124              : 
     125            4 :                     if (!last && !promise.stopped) {
     126            4 :                         const journalctlOptions = {
     127            4 :                             boot,
     128            4 :                             count: 0,
     129            4 :                             follow: true,
     130            4 :                             grep,
     131            4 :                             priority,
     132            4 :                             since,
     133            4 :                             until,
     134            4 :                         };
     135            4 :                         this.followingProcs.push(journal.journalctl(this.match, journalctlOptions)
     136            4 :                                 .fail(this.queryError)
     137            2 :                                 .stream(entries => {
     138            2 :                                     this.prependEntries(entries);
     139            2 :                                 }));
     140            4 :                     }
     141           11 :                     if (!all)
     142            8 :                         this.setState({ didntReachStart: true, cursor: oldest });
     143           11 :                 })
     144           11 :                 .always(() => this.setState({ loading: false }));
     145           12 :         this.procs.push(promise);
     146           12 :     }
     147              : 
     148            0 :     queryError(error) {
     149            0 :         this.setState({ error: cockpit.message(error) });
     150            0 :     }
     151              : 
     152           10 :     prependEntries(entries) {
     153            3 :         for (let i = 0; i < entries.length; i++) {
     154            3 :             const serviceTag = entries[i].SYSLOG_IDENTIFIER;
     155            3 :             this.renderer.prepend(entries[i]);
     156              :             // Only update if the service is not yet known
     157            3 :             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            3 :         }
     167            3 :         this.renderer.prepend_flush();
     168              : 
     169            3 :         this.setState({ logs: this.out.logs, loading: false });
     170           10 :     }
     171              : 
     172           11 :     appendEntries(entries) {
     173           11 :         for (let i = 0; i < entries.length; i++)
     174           11 :             this.renderer.append(entries[i]);
     175           11 :         this.renderer.append_flush();
     176              : 
     177           11 :         this.setState({ logs: this.out.logs, loading: false });
     178           11 :     }
     179              : 
     180           11 :     follow(cursor) {
     181           11 :         const { priority, until, grep } = this.options;
     182              : 
     183           11 :         const journalctlOptions = {
     184           11 :             count: 0,
     185            3 :             cursor: cursor || null,
     186           11 :             follow: true,
     187           11 :             grep,
     188           11 :             priority,
     189           11 :             until,
     190           11 :         };
     191           11 :         this.followingProcs.push(journal.journalctl(this.match, journalctlOptions)
     192           11 :                 .fail(this.queryError)
     193           10 :                 .stream(entries => {
     194           10 :                     if (entries[0].__CURSOR == cursor)
     195           10 :                         entries.shift();
     196           10 :                     this.prependEntries(entries);
     197           10 :                 }));
     198           11 :     }
     199              : 
     200           12 :     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           12 :         const currentServices = new Set();
     205           12 :         const service_options = Object.assign({ output: "verbose" }, options);
     206           12 :         let cmd = journal.build_cmd(match, service_options);
     207              : 
     208              :         // cribbed from Python's shlex.quote()
     209           12 :         cmd = cmd.map(i => `'` + i.replaceAll(`'`, `'"'"'`) + `'`).join(" ");
     210           12 :         cmd = "set -o pipefail; " + cmd + " | grep SYSLOG_IDENTIFIER= | sort -u";
     211           12 :         cockpit.spawn(["/bin/bash", "-ec", cmd], { superuser: "try", err: "message" })
     212           11 :                 .then(entries => {
     213           11 :                     entries.split("\n").forEach(entry => {
     214           11 :                         if (entry)
     215           11 :                             currentServices.add(entry.substring(entry.indexOf('=') + 1));
     216           11 :                     });
     217           11 :                 })
     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           11 :                 .finally(() => {
     224           11 :                     this.props.setCurrentIdentifiers(Array.from(currentServices));
     225           11 :                 });
     226           12 :     }
     227              : 
     228           12 :     stop() {
     229            9 :         this.procs.forEach(proc => proc.stop());
     230            9 :         this.followingProcs.forEach(proc => proc.stop());
     231           12 :     }
     232              : 
     233            1 :     stopFollowing() {
     234            1 :         this.followingProcs.forEach(proc => proc.stop());
     235            1 :     }
     236              : 
     237           12 :     render() {
     238           12 :         const { priority, grep } = this.options;
     239           12 :         const noLogs = !this.state.logs.length;
     240           12 :         let error = null;
     241           12 :         if (this.state.error)
     242           12 :             error = (
     243            2 :                 <Alert variant="danger"
     244            2 :                        isInline
     245            0 :                        actionClose={<AlertActionCloseButton onClose={() => this.setState({ error: undefined })} />}
     246            2 :                        title={_("Failed to fetch logs")}>
     247            2 :                     {this.state.error}
     248            2 :                 </Alert>
     249              :             );
     250              : 
     251           12 :         if (!this.state.logs.length && this.state.loading)
     252           12 :             return (
     253           12 :                 <>
     254           12 :                     {error}
     255           12 :                     <EmptyStatePanel loading title={_("Loading...")} />
     256           12 :                 </>
     257              :             );
     258              : 
     259              :         /* Journalctl command stream finished and there are not more entries to query */
     260            3 :         if (!this.state.logs.length && !this.state.didntReachStart && this.state.streamFinished) {
     261            3 :             return (
     262            3 :                 <div id="start-box" className="journal-start">
     263            3 :                     {error}
     264            3 :                     <EmptyStatePanel action={_("Clear all filters")}
     265            3 :                                      icon={ExclamationCircleIcon}
     266            3 :                                      actionVariant="link"
     267            0 :                                      onAction={() => cockpit.location.go('/')}
     268            3 :                                      paragraph={_("Can not find any logs using the current combination of filters.")}
     269            3 :                                      title={_("No logs found")}
     270            3 :                     />
     271            3 :                 </div>
     272              :             );
     273            3 :         }
     274           11 :         const loadEarlier = (
     275              :             /* Show 'Load earlier entries' button if we didn't reach start yet */
     276           11 :             this.state.didntReachStart
     277            8 :                 ? <EmptyStatePanel action={_("Load earlier entries")}
     278            4 :                              icon={noLogs ? ExclamationCircleIcon : undefined}
     279            8 :                              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            4 :                              paragraph={noLogs ? _("You may try to load older entries.") : ""}
     311            4 :                              title={noLogs ? _("No logs found") : ""}
     312            8 :                              loading={this.state.loading} />
     313           11 :                 : null
     314              :         );
     315              : 
     316           12 :         return (
     317           12 :             <>
     318           12 :                 {error}
     319           12 :                 {this.state.logs.length
     320           11 :                     ? <div id="journal-logs" className="panel panel-default cockpit-log-panel" role="table">
     321           11 :                         {this.state.logs}
     322           11 :                     </div>
     323            4 :                     : null}
     324           12 :                 <div id="start-box" className="journal-start">
     325           12 :                     {loadEarlier}
     326           12 :                 </div>
     327           12 :             </>
     328              :         );
     329           12 :     }
     330           12 : }
        

Generated by: LCOV version 2.0-1