Line data Source code
1 : /*
2 : * Copyright (C) 2020 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 10 : import cockpit from "cockpit";
7 : import { journal } from "journal";
8 : import { superuser } from "superuser";
9 :
10 10 : 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 10 : superuser.reload_page_on_change();
21 :
22 10 : const _ = cockpit.gettext;
23 : // Stop stream when entries > QUERY_MORE after clicking 'Load earlier entries' button
24 10 : const QUERY_MORE = 1000;
25 : // Stop stream when entries > QUERY_COUNT
26 10 : const QUERY_COUNT = 5000;
27 :
28 10 : export class JournalBox extends React.Component {
29 10 : constructor(props) {
30 10 : super(props);
31 10 : this.state = {
32 10 : cursor: undefined,
33 10 : loading: true,
34 10 : logs: [],
35 10 : streamFinished: false,
36 10 : didntReachStart: true,
37 10 : };
38 :
39 10 : this.appendEntries = this.appendEntries.bind(this);
40 10 : this.followingProcs = [];
41 10 : this.loadServiceFilters = this.loadServiceFilters.bind(this);
42 10 : this.prependEntries = this.prependEntries.bind(this);
43 10 : this.procs = [];
44 10 : this.updateQuery = this.updateQuery.bind(this);
45 10 : this.queryError = this.queryError.bind(this);
46 :
47 10 : this.options = cockpit.location.options;
48 10 : }
49 :
50 10 : componentDidMount() {
51 10 : cockpit.addEventListener("locationchanged", this.updateQuery);
52 10 : this.updateQuery();
53 10 : }
54 :
55 10 : 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 10 : }
68 :
69 10 : updateQuery() {
70 10 : this.stop();
71 :
72 10 : this.options = cockpit.location.options;
73 10 : this.match = getGrepFiltersFromOptions({ options: this.options })[1];
74 10 : const { dataFollowing, defaultSince, updateIdentifiersList, setFilteredQuery } = this.props;
75 10 : const { priority, grep, boot, since, until } = this.options;
76 2 : let last = dataFollowing ? null : 1;
77 10 : let count = 0;
78 10 : let oldest = null;
79 8 : const all = boot === undefined && since === undefined && until === undefined;
80 :
81 10 : this.out = new JournalOutput(this.options);
82 10 : this.renderer = journal.renderer(this.out);
83 :
84 10 : 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 10 : const journalctlOptions = {
91 10 : boot,
92 10 : follow: false, /* follow: Show only the most recent journal entries, and continuously print new entries as they are appended to the journal. */
93 10 : grep,
94 10 : priority,
95 10 : reverse: true, /* reverse: Reverse output so that the newest entries are displayed first */
96 8 : since: since || defaultSince,
97 10 : until
98 10 : };
99 :
100 10 : setFilteredQuery(getFilteredQuery({ match: this.match, options: journalctlOptions }));
101 :
102 10 : if (updateIdentifiersList)
103 10 : this.loadServiceFilters(tags_match, journalctlOptions);
104 :
105 10 : this.setState({ loading: true, didntReachStart: false, streamFinished: false, logs: [] });
106 :
107 10 : const promise = journal.journalctl(this.match, journalctlOptions)
108 10 : .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 10 : this.procs.push(promise);
146 10 : }
147 :
148 0 : queryError(error) {
149 0 : this.setState({ error: cockpit.message(error) });
150 0 : }
151 :
152 8 : 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 8 : }
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 8 : .stream(entries => {
194 8 : if (entries[0].__CURSOR == cursor)
195 8 : entries.shift();
196 8 : this.prependEntries(entries);
197 8 : }));
198 9 : }
199 :
200 10 : 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 10 : const currentServices = new Set();
205 10 : const service_options = Object.assign({ output: "verbose" }, options);
206 10 : let cmd = journal.build_cmd(match, service_options);
207 :
208 : // cribbed from Python's shlex.quote()
209 10 : cmd = cmd.map(i => `'` + i.replaceAll(`'`, `'"'"'`) + `'`).join(" ");
210 10 : cmd = "set -o pipefail; " + cmd + " | grep SYSLOG_IDENTIFIER= | sort -u";
211 10 : 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 10 : }
227 :
228 10 : stop() {
229 9 : this.procs.forEach(proc => proc.stop());
230 9 : this.followingProcs.forEach(proc => proc.stop());
231 10 : }
232 :
233 1 : stopFollowing() {
234 1 : this.followingProcs.forEach(proc => proc.stop());
235 1 : }
236 :
237 10 : render() {
238 10 : const { priority, grep } = this.options;
239 10 : const noLogs = !this.state.logs.length;
240 10 : let error = null;
241 10 : if (this.state.error)
242 10 : 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 10 : if (!this.state.logs.length && this.state.loading)
252 10 : return (
253 10 : <>
254 10 : {error}
255 10 : <EmptyStatePanel loading title={_("Loading...")} />
256 10 : </>
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 9 : : null
314 : );
315 :
316 10 : return (
317 10 : <>
318 10 : {error}
319 10 : {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 10 : <div id="start-box" className="journal-start">
325 10 : {loadEarlier}
326 10 : </div>
327 10 : </>
328 : );
329 10 : }
330 10 : }
|