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