LCOV - code coverage report
Current view: top level - pkg/systemd - logs.jsx Coverage Total Hit
Test: cockpit Lines: 98.0 % 295 289
Test Date: 2026-06-16 14:09:37

            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 'cockpit-dark-theme'; // once per page
       8              : 
       9            9 : import cockpit from "cockpit";
      10            9 : import React, { useState, useEffect } from 'react';
      11            9 : import { createRoot } from 'react-dom/client';
      12              : 
      13              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      14              : import { ClipboardCopy } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js";
      15              : import { Page, PageSection, } from "@patternfly/react-core/dist/esm/components/Page/index.js";
      16              : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
      17              : import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchInput/index.js";
      18              : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
      19              : import { Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem, ToolbarToggleGroup } from "@patternfly/react-core/dist/esm/components/Toolbar/index.js";
      20              : import {
      21              :     ExternalLinkSquareAltIcon,
      22              :     FilterIcon,
      23              :     HelpIcon,
      24              : } from '@patternfly/react-icons';
      25              : 
      26              : import { SimpleSelect } from "cockpit-components-simple-select";
      27              : import { TypeaheadSelect } from "cockpit-components-typeahead-select";
      28              : 
      29              : import {
      30              :     checkJournalctlGrep,
      31              :     getGrepFiltersFromOptions,
      32              :     getOptionsFromTextInput,
      33              : } from "./logsHelpers.js";
      34              : import { JournalBox } from "./logsJournal.jsx";
      35              : import { LogEntry } from "./logDetails.jsx";
      36              : 
      37              : import { usePageLocation } from "hooks";
      38              : 
      39              : import "./logs.scss";
      40              : 
      41            9 : const _ = cockpit.gettext;
      42              : 
      43            9 : const timeFilterOptions = [
      44            9 :     { key: 1, value: { key: "boot", value: 0 }, content: _("Current boot") },
      45            9 :     { key: 2, value: { key: "boot", value: "-1" }, content: _("Previous boot") },
      46            9 :     { key: 3, value: { key: "since", value: "-24hours", default: true }, content: _("Last 24 hours") },
      47            9 :     { key: 4, value: { key: "since", value: "-7days" }, content: _("Last 7 days") },
      48            9 : ];
      49              : 
      50            9 : const journalPrioOptions = [
      51            9 :     { value: "emerg", content: _("Only emergency") },
      52            9 :     { value: "alert", content: _("Alert and above") },
      53            9 :     { value: "crit", content: _("Critical and above") },
      54            9 :     { value: "err", content: _("Error and above") },
      55            9 :     { value: "warning", content: _("Warning and above") },
      56            9 :     { value: "notice", content: _("Notice and above") },
      57            9 :     { value: "info", content: _("Info and above") },
      58            9 :     { value: "debug", content: _("Debug and above") },
      59            9 : ];
      60              : 
      61            9 : const getPrioFilterOption = options => {
      62              :     // `prio` is a legacy name. Accept it, but don't generate it
      63            5 :     return options.priority || options.prio;
      64            9 : };
      65              : 
      66            9 : const getTimeFilterOption = options => {
      67            7 :     function find_key(key, value) {
      68            2 :         return timeFilterOptions.find(o => o.value.key == key && o.value.value == value)?.value;
      69            7 :     }
      70            9 :     if (options.boot)
      71            2 :         return find_key('boot', options.boot);
      72            9 :     else if (options.since)
      73            7 :         return find_key('since', options.since);
      74            9 :     return timeFilterOptions.find(option => 'default' in option.value)?.value; // Use the default key
      75            9 : };
      76              : 
      77            9 : export const LogsPage = () => {
      78            9 :     const { path, options } = usePageLocation();
      79            2 :     let follow = !(options.follow && options.follow === "false");
      80              : 
      81            2 :     if (options.boot && options.boot !== "0") // Don't follow if specific boot is picked
      82            2 :         follow = false;
      83              : 
      84              :     // If priority not specified use err
      85            9 :     if (!options.priority && !options.prio)
      86            9 :         options.priority = 'err';
      87              : 
      88            9 :     const full_grep = getGrepFiltersFromOptions({ options })[0];
      89              : 
      90              :     /* Initial state */
      91            9 :     const [currentIdentifiers, setCurrentIdentifiers] = useState([]);
      92            9 :     const [dataFollowing, setDataFollowing] = useState(follow);
      93            9 :     const [filteredQuery, setFilteredQuery] = useState(undefined);
      94            9 :     const [journalPrio, setJournalPrio] = useState(getPrioFilterOption(options));
      95            8 :     const [identifiersFilter, setIdentifiersFilter] = useState(options.tag || "all");
      96            9 :     const [showTextSearch, setShowTextSearch] = useState(false);
      97            9 :     const [textFilter, setTextFilter] = useState(full_grep);
      98            9 :     const [timeFilter, setTimeFilter] = useState(getTimeFilterOption(options));
      99            9 :     const [updateIdentifiersList, setUpdateIdentifiersList] = useState(true);
     100              : 
     101            9 :     useEffect(() => {
     102            9 :         checkJournalctlGrep(setShowTextSearch);
     103              : 
     104            9 :         function onNavigate() {
     105            9 :             const { options, path } = cockpit.location;
     106            9 :             const full_grep = getGrepFiltersFromOptions({ options })[0];
     107              : 
     108            9 :             if (path.length == 1) return;
     109              : 
     110            8 :             setJournalPrio(getPrioFilterOption(options));
     111            6 :             setIdentifiersFilter(options.tag || "all");
     112            9 :             setTextFilter(full_grep);
     113            9 :             setTimeFilter(getTimeFilterOption(options));
     114            9 :         }
     115              : 
     116            9 :         cockpit.addEventListener("locationchanged", onNavigate);
     117            0 :         return () => cockpit.removeEventListener("locationchanged", onNavigate);
     118            9 :     }, []);
     119              : 
     120            6 :     if (path.length == 1) {
     121            6 :         return <LogEntry />;
     122            1 :     } else if (path.length > 1) { /* redirect */
     123            1 :         console.warn("not a journal location: " + path);
     124            1 :         cockpit.location = '';
     125            1 :     }
     126              : 
     127            4 :     const updateUrl = (options) => {
     128            4 :         cockpit.location.go([], options);
     129            4 :     };
     130              : 
     131            3 :     const onJournalPrioChange = (value) => {
     132            3 :         setUpdateIdentifiersList(true);
     133              : 
     134            3 :         updateUrl(Object.assign(options, { priority: value }));
     135            3 :     };
     136              : 
     137            2 :     const onIdentifiersFilterChange = (value) => {
     138            2 :         setUpdateIdentifiersList(false);
     139              : 
     140            2 :         if (value == "all") {
     141            2 :             delete options.tag;
     142            2 :             updateUrl(Object.assign(options));
     143            2 :         } else {
     144            2 :             updateUrl(Object.assign(options, { tag: value }));
     145            2 :         }
     146            2 :     };
     147              : 
     148            2 :     const onTextFilterChange = (value) => {
     149            2 :         setUpdateIdentifiersList(true);
     150              : 
     151            2 :         updateUrl(Object.assign(getOptionsFromTextInput(value)));
     152            2 :     };
     153              : 
     154            1 :     const onTimeFilterChange = (newTimeFilter) => {
     155            1 :         setUpdateIdentifiersList(true);
     156              : 
     157            1 :         if (newTimeFilter.key == 'boot' && newTimeFilter.value !== "0") // Don't follow if specific boot is picked
     158            1 :             setDataFollowing(false);
     159            1 :         else if (options.boot && options.boot !== "0" && newTimeFilter.key !== "boot") // Start following is specific boot is removed
     160            1 :             setDataFollowing(true);
     161              : 
     162              :         // Remove all parameters which can be set up using filters
     163            1 :         delete options.boot;
     164            1 :         delete options.since;
     165              : 
     166            1 :         cockpit.location.go([], Object.assign(options, { [newTimeFilter.key]: newTimeFilter.value }));
     167            1 :     };
     168              : 
     169            9 :     return (
     170            9 :         <Page className='pf-m-no-sidebar'>
     171            9 :             <PageSection hasBodyWrapper={false} id="journal" className="journal-filters">
     172            9 :                 <Toolbar hasNoPadding>
     173            9 :                     <ToolbarContent>
     174            9 :                         <ToolbarToggleGroup className="pf-v6-u-flex-wrap pf-v6-u-flex-grow-1 pf-v6-u-align-items-flex-start" toggleIcon={<><span className="pf-v6-c-button__icon pf-m-start"><FilterIcon /></span>{_("Toggle filters")}</>} breakpoint="lg">
     175            9 :                             <ToolbarGroup>
     176            9 :                                 <ToolbarItem>
     177            9 :                                     <SimpleSelect
     178            9 :                                         toggleProps={{ id: "logs-predefined-filters" }}
     179            9 :                                         placeholder={_("Time")}
     180            9 :                                         onSelect={onTimeFilterChange}
     181            9 :                                         options={timeFilterOptions}
     182            9 :                                         selected={timeFilter} />
     183            9 :                                 </ToolbarItem>
     184              : 
     185            9 :                                 <ToolbarItem variant="label">
     186            9 :                                     {_("Priority")}
     187            9 :                                 </ToolbarItem>
     188            9 :                                 <ToolbarItem>
     189            9 :                                     <SimpleSelect
     190            9 :                                         toggleProps={{ id: "journal-prio-menu" }}
     191            9 :                                         placeholder={_("Priority")}
     192            9 :                                         onSelect={onJournalPrioChange}
     193            9 :                                         options={journalPrioOptions}
     194            9 :                                         selected={journalPrio} />
     195            9 :                                 </ToolbarItem>
     196              : 
     197            9 :                                 <ToolbarItem variant="label">
     198            9 :                                     {_("Identifier")}
     199            9 :                                 </ToolbarItem>
     200            9 :                                 <ToolbarItem id="journal-identifier-menu" className="journal-filters-identifier-menu">
     201            9 :                                     <IdentifiersFilter currentIdentifiers={currentIdentifiers}
     202            9 :                                                     onIdentifiersFilterChange={onIdentifiersFilterChange}
     203            9 :                                                     identifiersFilter={identifiersFilter} />
     204            9 :                                 </ToolbarItem>
     205            9 :                             </ToolbarGroup>
     206              : 
     207            9 :                             <ToolbarGroup>
     208            9 :                                 {showTextSearch &&
     209            9 :                                 <>
     210            9 :                                     <ToolbarItem variant="label">
     211            9 :                                         {_("Filters")}
     212            9 :                                     </ToolbarItem>
     213            9 :                                     <ToolbarItem className="text-search">
     214            9 :                                         <TextFilter id="journal-grep"
     215            9 :                                                     className="journal-filters-grep"
     216            9 :                                                     key={textFilter}
     217            9 :                                                     textFilter={textFilter}
     218            9 :                                                     onTextFilterChange={onTextFilterChange}
     219            9 :                                                     filteredQuery={filteredQuery} />
     220            9 :                                     </ToolbarItem>
     221            9 :                                 </>}
     222            9 :                                 <ToolbarItem variant="separator" />
     223              : 
     224            9 :                                 <ToolbarItem>
     225            9 :                                     <Button id="journal-follow"
     226            9 :                                                 variant="secondary"
     227            2 :                                                 isDisabled={options.boot && options.boot !== "0"}
     228            1 :                                                 onClick={() => {
     229              :                                                     // Reset time filter if following mode is now selected but we are on a specific boot
     230            0 :                                                     if (!dataFollowing && timeFilter && timeFilter.key == "boot" && timeFilter.value !== "0") {
     231            0 :                                                         setTimeFilter(undefined);
     232            0 :                                                     }
     233              : 
     234            1 :                                                     setDataFollowing(!dataFollowing);
     235            1 :                                                 }
     236              :                                                 }
     237            9 :                                                 data-following={dataFollowing}>
     238            2 :                                         {dataFollowing ? _("Pause") : _("Resume")}
     239            9 :                                     </Button>
     240            9 :                                 </ToolbarItem>
     241            9 :                             </ToolbarGroup>
     242            9 :                         </ToolbarToggleGroup>
     243            9 :                     </ToolbarContent>
     244            9 :                 </Toolbar>
     245              : 
     246            9 :             </PageSection>
     247            9 :             <PageSection hasBodyWrapper={false}
     248            9 :                          id="journal-box"
     249            9 :                          className="journal-filters-box">
     250            9 :                 <JournalBox dataFollowing={dataFollowing}
     251            3 :                             defaultSince={timeFilter ? timeFilter.value : getTimeFilterOption({}).value}
     252            9 :                             currentIdentifiers={currentIdentifiers}
     253            9 :                             setCurrentIdentifiers={setCurrentIdentifiers}
     254            9 :                             setFilteredQuery={setFilteredQuery}
     255            9 :                             updateIdentifiersList={updateIdentifiersList}
     256            9 :                             setUpdateIdentifiersList={setUpdateIdentifiersList} />
     257            9 :             </PageSection>
     258            9 :         </Page>
     259              :     );
     260            9 : };
     261              : 
     262            9 : const IdentifiersFilter = ({ identifiersFilter, onIdentifiersFilterChange, currentIdentifiers }) => {
     263            9 :     let identifiersArray;
     264            9 :     if (currentIdentifiers !== undefined) {
     265            9 :         identifiersArray = [
     266            9 :             { value: "all", content: _("All") }
     267            9 :         ];
     268            9 :         if (currentIdentifiers.length > 0) {
     269            9 :             identifiersArray.push({ decorator: "divider", key: "divider" });
     270            9 :         }
     271            9 :         identifiersArray = identifiersArray.concat(
     272            9 :             currentIdentifiers
     273            8 :                     .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
     274            9 :                     .map(unit => ({ value: unit, content: unit }))
     275            9 :         );
     276            1 :     } else {
     277            1 :         identifiersArray = [
     278            1 :             { value: identifiersFilter, content: identifiersFilter, isDisabled: true }
     279            1 :         ];
     280            1 :     }
     281              : 
     282            9 :     return (
     283            9 :         <TypeaheadSelect selectOptions={identifiersArray}
     284            9 :                          isScrollable
     285            9 :                          selected={identifiersFilter}
     286            9 :                          selectedIsTrusted
     287            2 :                          onSelect={(e, selection) => { onIdentifiersFilterChange(selection) }}
     288            0 :                          onClearSelection={identifiersFilter != "all" && (() => { onIdentifiersFilterChange("all") })}
     289            9 :         />
     290              :     );
     291            9 : };
     292              : 
     293            9 : const TextFilter = ({ textFilter, onTextFilterChange, filteredQuery }) => {
     294            9 :     const [unsubmittedTextFilter, setUnsubmittedTextFilter] = useState(textFilter);
     295            9 :     const sinceUntilBody = _("Date specifications should be of the format YYYY-MM-DD hh:mm:ss. Alternatively the strings 'yesterday', 'today', 'tomorrow' are understood. 'now' refers to the current time. Finally, relative times may be specified, prefixed with '-' or '+'");
     296              : 
     297            9 :     const sinceLabel = (
     298            9 :         <>
     299            9 :             {_("Since")}
     300            9 :             <Popover headerContent={_("Start showing entries on or newer than the specified date.")}
     301            9 :                      bodyContent={sinceUntilBody}>
     302            9 :                 <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
     303            9 :             </Popover>
     304            9 :         </>
     305              :     );
     306              : 
     307            9 :     const untilLabel = (
     308            9 :         <>
     309            9 :             {_("Until")}
     310            9 :             <Popover headerContent={_("Start showing entries on or older than the specified date.")}
     311            9 :                      bodyContent={sinceUntilBody}>
     312            9 :                 <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
     313            9 :             </Popover>
     314            9 :         </>
     315              :     );
     316              : 
     317            9 :     const bootLabel = (
     318            9 :         <>
     319            9 :             {_("Boot")}
     320            9 :             <Popover headerContent={_("Show messages from a specific boot.")}
     321            9 :                      bodyContent={_("This will add a match for '_BOOT_ID='. If not specified the logs for the current boot will be shown. If the boot ID is omitted, a positive offset will look up the boots starting from the beginning of the journal, and an equal-or-less-than zero offset will look up boots starting from the end of the journal. Thus, 1 means the first boot found in the journal in chronological order, 2 the second and so on; while -0 is the last boot, -1 the boot before last, and so on.")}>
     322            9 :                 <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
     323            9 :             </Popover>
     324            9 :         </>
     325              :     );
     326              : 
     327            9 :     const serviceLabel = (
     328            9 :         <>
     329            9 :             {_("Unit")}
     330            9 :             <Popover headerContent={_("Show messages for the specified systemd unit.")}
     331            9 :                      bodyContent={_("This will add match for '_SYSTEMD_UNIT=', 'COREDUMP_UNIT=' and 'UNIT=' to find all possible messages for the given unit. Can contain more units separated by comma.")}>
     332            9 :                 <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
     333            9 :             </Popover>
     334            9 :         </>
     335              :     );
     336              : 
     337            9 :     const freeTextLabel = (
     338            9 :         <>
     339            9 :             {_("Free-form search")}
     340            9 :             <Popover headerContent={_("Show messages containing given string.")}
     341            9 :                      bodyContent={_("Any text string in the logs messages can be filtered. The string can also be in the form of a regular expression. Also supports filtering by message log fields. These are space separated values, in form FIELD=VALUE, where value can be comma separated list of possible values.")}>
     342            9 :                 <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
     343            9 :             </Popover>
     344            9 :         </>
     345              :     );
     346              : 
     347            9 :     const searchInputAttributes = [
     348            9 :         { attr: "since", display: sinceLabel },
     349            9 :         { attr: "until", display: untilLabel },
     350            9 :         { attr: "boot", display: bootLabel },
     351            9 :         { attr: "unit", display: serviceLabel },
     352            9 :         { attr: "priority" }, // Hide this with CSS
     353            9 :         { attr: "tag" }, // Hide this with CSS
     354            9 :     ];
     355              : 
     356            9 :     return (
     357            9 :         <SearchInput attributes={searchInputAttributes}
     358            9 :                      hasWordsAttrLabel={freeTextLabel}
     359            9 :                      advancedSearchDelimiter=":"
     360            9 :                      id="journal-grep"
     361            0 :                      onClear={() => { onTextFilterChange(""); setUnsubmittedTextFilter("") }}
     362            9 :                      placeholder={_("Type to filter")}
     363            9 :                      value={unsubmittedTextFilter}
     364            2 :                      onChange={(_, val) => setUnsubmittedTextFilter(val)}
     365            9 :                      resetButtonLabel={_("Reset")}
     366            9 :                      submitSearchButtonLabel={_("Search")}
     367            9 :                      formAdditionalItems={<Stack hasGutter>
     368            9 :                          <Button variant="link" component="a" isInline
     369            9 :                                      href="https://www.freedesktop.org/software/systemd/man/latest/journalctl.html"
     370            9 :                                      icon={<ExternalLinkSquareAltIcon />} iconPosition="right"
     371            9 :                                      target="blank" rel="noopener noreferrer">
     372            9 :                              {_("journalctl manpage")}
     373            9 :                          </Button>
     374            9 :                          <ClipboardCopy clickTip={_("Successfully copied to clipboard")}
     375            9 :                                             isReadOnly
     376            9 :                                             hoverTip={_("Copy to clipboard")}
     377            9 :                                             id="journal-cmd-copy"
     378            9 :                                             isCode>
     379            9 :                              {filteredQuery}
     380            9 :                          </ClipboardCopy>
     381            9 :                      </Stack>}
     382            2 :                      onSearch={() => onTextFilterChange(unsubmittedTextFilter)} />
     383              :     );
     384            9 : };
     385              : 
     386            9 : function init() {
     387            9 :     const root = createRoot(document.getElementById('logs'));
     388            9 :     root.render(<LogsPage />);
     389            9 : }
     390              : 
     391            9 : document.addEventListener("DOMContentLoaded", init);
        

Generated by: LCOV version 2.0-1