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 11 : import cockpit from "cockpit";
10 11 : import React, { useState, useEffect } from 'react';
11 11 : 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 11 : const _ = cockpit.gettext;
42 :
43 11 : const timeFilterOptions = [
44 11 : { key: 1, value: { key: "boot", value: 0 }, content: _("Current boot") },
45 11 : { key: 2, value: { key: "boot", value: "-1" }, content: _("Previous boot") },
46 11 : { key: 3, value: { key: "since", value: "-24hours", default: true }, content: _("Last 24 hours") },
47 11 : { key: 4, value: { key: "since", value: "-7days" }, content: _("Last 7 days") },
48 11 : ];
49 :
50 11 : const journalPrioOptions = [
51 11 : { value: "emerg", content: _("Only emergency") },
52 11 : { value: "alert", content: _("Alert and above") },
53 11 : { value: "crit", content: _("Critical and above") },
54 11 : { value: "err", content: _("Error and above") },
55 11 : { value: "warning", content: _("Warning and above") },
56 11 : { value: "notice", content: _("Notice and above") },
57 11 : { value: "info", content: _("Info and above") },
58 11 : { value: "debug", content: _("Debug and above") },
59 11 : ];
60 :
61 11 : const getPrioFilterOption = options => {
62 : // `prio` is a legacy name. Accept it, but don't generate it
63 6 : return options.priority || options.prio;
64 11 : };
65 :
66 11 : 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 11 : if (options.boot)
71 3 : return find_key('boot', options.boot);
72 11 : else if (options.since)
73 8 : return find_key('since', options.since);
74 11 : return timeFilterOptions.find(option => 'default' in option.value)?.value; // Use the default key
75 11 : };
76 :
77 11 : export const LogsPage = () => {
78 11 : const { path, options } = usePageLocation();
79 3 : let follow = !(options.follow && options.follow === "false");
80 :
81 3 : if (options.boot && options.boot !== "0") // Don't follow if specific boot is picked
82 3 : follow = false;
83 :
84 : // If priority not specified use err
85 11 : if (!options.priority && !options.prio)
86 11 : options.priority = 'err';
87 :
88 11 : const full_grep = getGrepFiltersFromOptions({ options })[0];
89 :
90 : /* Initial state */
91 11 : const [currentIdentifiers, setCurrentIdentifiers] = useState([]);
92 11 : const [dataFollowing, setDataFollowing] = useState(follow);
93 11 : const [filteredQuery, setFilteredQuery] = useState(undefined);
94 11 : const [journalPrio, setJournalPrio] = useState(getPrioFilterOption(options));
95 10 : const [identifiersFilter, setIdentifiersFilter] = useState(options.tag || "all");
96 11 : const [showTextSearch, setShowTextSearch] = useState(false);
97 11 : const [textFilter, setTextFilter] = useState(full_grep);
98 11 : const [timeFilter, setTimeFilter] = useState(getTimeFilterOption(options));
99 11 : const [updateIdentifiersList, setUpdateIdentifiersList] = useState(true);
100 :
101 11 : useEffect(() => {
102 11 : 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 11 : cockpit.addEventListener("locationchanged", onNavigate);
117 0 : return () => cockpit.removeEventListener("locationchanged", onNavigate);
118 11 : }, []);
119 :
120 7 : if (path.length == 1) {
121 7 : return <LogEntry />;
122 2 : } else if (path.length > 1) { /* redirect */
123 2 : console.warn("not a journal location: " + path);
124 2 : cockpit.location = '';
125 2 : }
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 11 : return (
170 11 : <Page className='pf-m-no-sidebar'>
171 11 : <PageSection hasBodyWrapper={false} id="journal" className="journal-filters">
172 11 : <Toolbar hasNoPadding>
173 11 : <ToolbarContent>
174 11 : <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 11 : <ToolbarGroup>
176 11 : <ToolbarItem>
177 11 : <SimpleSelect
178 11 : toggleProps={{ id: "logs-predefined-filters" }}
179 11 : placeholder={_("Time")}
180 11 : onSelect={onTimeFilterChange}
181 11 : options={timeFilterOptions}
182 11 : selected={timeFilter} />
183 11 : </ToolbarItem>
184 :
185 11 : <ToolbarItem variant="label">
186 11 : {_("Priority")}
187 11 : </ToolbarItem>
188 11 : <ToolbarItem>
189 11 : <SimpleSelect
190 11 : toggleProps={{ id: "journal-prio-menu" }}
191 11 : placeholder={_("Priority")}
192 11 : onSelect={onJournalPrioChange}
193 11 : options={journalPrioOptions}
194 11 : selected={journalPrio} />
195 11 : </ToolbarItem>
196 :
197 11 : <ToolbarItem variant="label">
198 11 : {_("Identifier")}
199 11 : </ToolbarItem>
200 11 : <ToolbarItem id="journal-identifier-menu" className="journal-filters-identifier-menu">
201 11 : <IdentifiersFilter currentIdentifiers={currentIdentifiers}
202 11 : onIdentifiersFilterChange={onIdentifiersFilterChange}
203 11 : identifiersFilter={identifiersFilter} />
204 11 : </ToolbarItem>
205 11 : </ToolbarGroup>
206 :
207 11 : <ToolbarGroup>
208 11 : {showTextSearch &&
209 11 : <>
210 11 : <ToolbarItem variant="label">
211 11 : {_("Filters")}
212 11 : </ToolbarItem>
213 11 : <ToolbarItem className="text-search">
214 11 : <TextFilter id="journal-grep"
215 11 : className="journal-filters-grep"
216 11 : key={textFilter}
217 11 : textFilter={textFilter}
218 11 : onTextFilterChange={onTextFilterChange}
219 11 : filteredQuery={filteredQuery} />
220 11 : </ToolbarItem>
221 11 : </>}
222 11 : <ToolbarItem variant="separator" />
223 :
224 11 : <ToolbarItem>
225 11 : <Button id="journal-follow"
226 11 : variant="secondary"
227 3 : 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 11 : data-following={dataFollowing}>
238 3 : {dataFollowing ? _("Pause") : _("Resume")}
239 11 : </Button>
240 11 : </ToolbarItem>
241 11 : </ToolbarGroup>
242 11 : </ToolbarToggleGroup>
243 11 : </ToolbarContent>
244 11 : </Toolbar>
245 :
246 11 : </PageSection>
247 11 : <PageSection hasBodyWrapper={false}
248 11 : id="journal-box"
249 11 : className="journal-filters-box">
250 11 : <JournalBox dataFollowing={dataFollowing}
251 4 : defaultSince={timeFilter ? timeFilter.value : getTimeFilterOption({}).value}
252 11 : currentIdentifiers={currentIdentifiers}
253 11 : setCurrentIdentifiers={setCurrentIdentifiers}
254 11 : setFilteredQuery={setFilteredQuery}
255 11 : updateIdentifiersList={updateIdentifiersList}
256 11 : setUpdateIdentifiersList={setUpdateIdentifiersList} />
257 11 : </PageSection>
258 11 : </Page>
259 : );
260 11 : };
261 :
262 11 : const IdentifiersFilter = ({ identifiersFilter, onIdentifiersFilterChange, currentIdentifiers }) => {
263 11 : let identifiersArray;
264 11 : if (currentIdentifiers !== undefined) {
265 11 : identifiersArray = [
266 11 : { value: "all", content: _("All") }
267 11 : ];
268 11 : if (currentIdentifiers.length > 0) {
269 11 : identifiersArray.push({ decorator: "divider", key: "divider" });
270 11 : }
271 11 : identifiersArray = identifiersArray.concat(
272 11 : currentIdentifiers
273 10 : .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
274 11 : .map(unit => ({ value: unit, content: unit }))
275 11 : );
276 2 : } else {
277 2 : identifiersArray = [
278 2 : { value: identifiersFilter, content: identifiersFilter, isDisabled: true }
279 2 : ];
280 2 : }
281 :
282 11 : return (
283 11 : <TypeaheadSelect selectOptions={identifiersArray}
284 11 : isScrollable
285 11 : selected={identifiersFilter}
286 11 : selectedIsTrusted
287 2 : onSelect={(e, selection) => { onIdentifiersFilterChange(selection) }}
288 0 : onClearSelection={identifiersFilter != "all" && (() => { onIdentifiersFilterChange("all") })}
289 11 : />
290 : );
291 11 : };
292 :
293 11 : const TextFilter = ({ textFilter, onTextFilterChange, filteredQuery }) => {
294 11 : const [unsubmittedTextFilter, setUnsubmittedTextFilter] = useState(textFilter);
295 11 : 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 11 : const sinceLabel = (
298 11 : <>
299 11 : {_("Since")}
300 11 : <Popover headerContent={_("Start showing entries on or newer than the specified date.")}
301 11 : bodyContent={sinceUntilBody}>
302 11 : <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
303 11 : </Popover>
304 11 : </>
305 : );
306 :
307 11 : const untilLabel = (
308 11 : <>
309 11 : {_("Until")}
310 11 : <Popover headerContent={_("Start showing entries on or older than the specified date.")}
311 11 : bodyContent={sinceUntilBody}>
312 11 : <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
313 11 : </Popover>
314 11 : </>
315 : );
316 :
317 11 : const bootLabel = (
318 11 : <>
319 11 : {_("Boot")}
320 11 : <Popover headerContent={_("Show messages from a specific boot.")}
321 11 : 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 11 : <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
323 11 : </Popover>
324 11 : </>
325 : );
326 :
327 11 : const serviceLabel = (
328 11 : <>
329 11 : {_("Unit")}
330 11 : <Popover headerContent={_("Show messages for the specified systemd unit.")}
331 11 : 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 11 : <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
333 11 : </Popover>
334 11 : </>
335 : );
336 :
337 11 : const freeTextLabel = (
338 11 : <>
339 11 : {_("Free-form search")}
340 11 : <Popover headerContent={_("Show messages containing given string.")}
341 11 : 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 11 : <Button icon={<HelpIcon />} className="log-text-filter-popover-button" variant="plain" />
343 11 : </Popover>
344 11 : </>
345 : );
346 :
347 11 : const searchInputAttributes = [
348 11 : { attr: "since", display: sinceLabel },
349 11 : { attr: "until", display: untilLabel },
350 11 : { attr: "boot", display: bootLabel },
351 11 : { attr: "unit", display: serviceLabel },
352 11 : { attr: "priority" }, // Hide this with CSS
353 11 : { attr: "tag" }, // Hide this with CSS
354 11 : ];
355 :
356 11 : return (
357 11 : <SearchInput attributes={searchInputAttributes}
358 11 : hasWordsAttrLabel={freeTextLabel}
359 11 : advancedSearchDelimiter=":"
360 11 : id="journal-grep"
361 0 : onClear={() => { onTextFilterChange(""); setUnsubmittedTextFilter("") }}
362 11 : placeholder={_("Type to filter")}
363 11 : value={unsubmittedTextFilter}
364 2 : onChange={(_, val) => setUnsubmittedTextFilter(val)}
365 11 : resetButtonLabel={_("Reset")}
366 11 : submitSearchButtonLabel={_("Search")}
367 11 : formAdditionalItems={<Stack hasGutter>
368 11 : <Button variant="link" component="a" isInline
369 11 : href="https://www.freedesktop.org/software/systemd/man/latest/journalctl.html"
370 11 : icon={<ExternalLinkSquareAltIcon />} iconPosition="right"
371 11 : target="blank" rel="noopener noreferrer">
372 11 : {_("journalctl manpage")}
373 11 : </Button>
374 11 : <ClipboardCopy clickTip={_("Successfully copied to clipboard")}
375 11 : isReadOnly
376 11 : hoverTip={_("Copy to clipboard")}
377 11 : id="journal-cmd-copy"
378 11 : isCode>
379 11 : {filteredQuery}
380 11 : </ClipboardCopy>
381 11 : </Stack>}
382 2 : onSearch={() => onTextFilterChange(unsubmittedTextFilter)} />
383 : );
384 11 : };
385 :
386 11 : function init() {
387 11 : const root = createRoot(document.getElementById('logs'));
388 11 : root.render(<LogsPage />);
389 11 : }
390 :
391 11 : document.addEventListener("DOMContentLoaded", init);
|