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 10 : import cockpit from "cockpit";
10 10 : import React, { useState, useEffect } from 'react';
11 10 : 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 10 : const _ = cockpit.gettext;
42 :
43 10 : const timeFilterOptions = [
44 10 : { key: 1, value: { key: "boot", value: 0 }, content: _("Current boot") },
45 10 : { key: 2, value: { key: "boot", value: "-1" }, content: _("Previous boot") },
46 10 : { key: 3, value: { key: "since", value: "-24hours", default: true }, content: _("Last 24 hours") },
47 10 : { key: 4, value: { key: "since", value: "-7days" }, content: _("Last 7 days") },
48 10 : ];
49 :
50 10 : const journalPrioOptions = [
51 10 : { value: "emerg", content: _("Only emergency") },
52 10 : { value: "alert", content: _("Alert and above") },
53 10 : { value: "crit", content: _("Critical and above") },
54 10 : { value: "err", content: _("Error and above") },
55 10 : { value: "warning", content: _("Warning and above") },
56 10 : { value: "notice", content: _("Notice and above") },
57 10 : { value: "info", content: _("Info and above") },
58 10 : { value: "debug", content: _("Debug and above") },
59 10 : ];
60 :
61 10 : const getPrioFilterOption = options => {
62 : // `prio` is a legacy name. Accept it, but don't generate it
63 5 : return options.priority || options.prio;
64 10 : };
65 :
66 10 : 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 10 : if (options.boot)
71 2 : return find_key('boot', options.boot);
72 10 : else if (options.since)
73 7 : return find_key('since', options.since);
74 10 : return timeFilterOptions.find(option => 'default' in option.value)?.value; // Use the default key
75 10 : };
76 :
77 10 : export const LogsPage = () => {
78 10 : 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 10 : if (!options.priority && !options.prio)
86 10 : options.priority = 'err';
87 :
88 10 : const full_grep = getGrepFiltersFromOptions({ options })[0];
89 :
90 : /* Initial state */
91 10 : const [currentIdentifiers, setCurrentIdentifiers] = useState([]);
92 10 : const [dataFollowing, setDataFollowing] = useState(follow);
93 10 : const [filteredQuery, setFilteredQuery] = useState(undefined);
94 10 : const [journalPrio, setJournalPrio] = useState(getPrioFilterOption(options));
95 9 : const [identifiersFilter, setIdentifiersFilter] = useState(options.tag || "all");
96 10 : const [showTextSearch, setShowTextSearch] = useState(false);
97 10 : const [textFilter, setTextFilter] = useState(full_grep);
98 10 : const [timeFilter, setTimeFilter] = useState(getTimeFilterOption(options));
99 10 : const [updateIdentifiersList, setUpdateIdentifiersList] = useState(true);
100 :
101 10 : useEffect(() => {
102 10 : 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 10 : cockpit.addEventListener("locationchanged", onNavigate);
117 0 : return () => cockpit.removeEventListener("locationchanged", onNavigate);
118 10 : }, []);
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 10 : return (
170 10 : <Page className='pf-m-no-sidebar'>
171 10 : <PageSection hasBodyWrapper={false} id="journal" className="journal-filters">
172 10 : <Toolbar hasNoPadding>
173 10 : <ToolbarContent>
174 10 : <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 10 : <ToolbarGroup>
176 10 : <ToolbarItem>
177 10 : <SimpleSelect
178 10 : toggleProps={{ id: "logs-predefined-filters" }}
179 10 : placeholder={_("Time")}
180 10 : onSelect={onTimeFilterChange}
181 10 : options={timeFilterOptions}
182 10 : selected={timeFilter} />
183 10 : </ToolbarItem>
184 :
185 10 : <ToolbarItem variant="label">
186 10 : {_("Priority")}
187 10 : </ToolbarItem>
188 10 : <ToolbarItem>
189 10 : <SimpleSelect
190 10 : toggleProps={{ id: "journal-prio-menu" }}
191 10 : placeholder={_("Priority")}
192 10 : onSelect={onJournalPrioChange}
193 10 : options={journalPrioOptions}
194 10 : selected={journalPrio} />
195 10 : </ToolbarItem>
196 :
197 10 : <ToolbarItem variant="label">
198 10 : {_("Identifier")}
199 10 : </ToolbarItem>
200 10 : <ToolbarItem id="journal-identifier-menu" className="journal-filters-identifier-menu">
201 10 : <IdentifiersFilter currentIdentifiers={currentIdentifiers}
202 10 : onIdentifiersFilterChange={onIdentifiersFilterChange}
203 10 : identifiersFilter={identifiersFilter} />
204 10 : </ToolbarItem>
205 10 : </ToolbarGroup>
206 :
207 10 : <ToolbarGroup>
208 10 : {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 10 : <ToolbarItem variant="separator" />
223 :
224 10 : <ToolbarItem>
225 10 : <Button id="journal-follow"
226 10 : 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 10 : data-following={dataFollowing}>
238 2 : {dataFollowing ? _("Pause") : _("Resume")}
239 10 : </Button>
240 10 : </ToolbarItem>
241 10 : </ToolbarGroup>
242 10 : </ToolbarToggleGroup>
243 10 : </ToolbarContent>
244 10 : </Toolbar>
245 :
246 10 : </PageSection>
247 10 : <PageSection hasBodyWrapper={false}
248 10 : id="journal-box"
249 10 : className="journal-filters-box">
250 10 : <JournalBox dataFollowing={dataFollowing}
251 3 : defaultSince={timeFilter ? timeFilter.value : getTimeFilterOption({}).value}
252 10 : currentIdentifiers={currentIdentifiers}
253 10 : setCurrentIdentifiers={setCurrentIdentifiers}
254 10 : setFilteredQuery={setFilteredQuery}
255 10 : updateIdentifiersList={updateIdentifiersList}
256 10 : setUpdateIdentifiersList={setUpdateIdentifiersList} />
257 10 : </PageSection>
258 10 : </Page>
259 : );
260 10 : };
261 :
262 10 : const IdentifiersFilter = ({ identifiersFilter, onIdentifiersFilterChange, currentIdentifiers }) => {
263 10 : let identifiersArray;
264 10 : if (currentIdentifiers !== undefined) {
265 10 : identifiersArray = [
266 10 : { value: "all", content: _("All") }
267 10 : ];
268 9 : if (currentIdentifiers.length > 0) {
269 9 : identifiersArray.push({ decorator: "divider", key: "divider" });
270 9 : }
271 10 : identifiersArray = identifiersArray.concat(
272 10 : currentIdentifiers
273 8 : .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
274 9 : .map(unit => ({ value: unit, content: unit }))
275 10 : );
276 1 : } else {
277 1 : identifiersArray = [
278 1 : { value: identifiersFilter, content: identifiersFilter, isDisabled: true }
279 1 : ];
280 1 : }
281 :
282 10 : return (
283 10 : <TypeaheadSelect selectOptions={identifiersArray}
284 10 : isScrollable
285 10 : selected={identifiersFilter}
286 10 : selectedIsTrusted
287 2 : onSelect={(e, selection) => { onIdentifiersFilterChange(selection) }}
288 0 : onClearSelection={identifiersFilter != "all" && (() => { onIdentifiersFilterChange("all") })}
289 10 : />
290 : );
291 10 : };
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 10 : function init() {
387 10 : const root = createRoot(document.getElementById('logs'));
388 10 : root.render(<LogsPage />);
389 10 : }
390 :
391 10 : document.addEventListener("DOMContentLoaded", init);
|