Line data Source code
1 : /*
2 : * Copyright (C) 2015 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 51 : import cockpit from "cockpit";
7 : import * as timeformat from "timeformat";
8 :
9 51 : const _ = cockpit.gettext;
10 :
11 51 : export const journal = { };
12 :
13 : /**
14 : * journalctl([match, ...], [options])
15 : * @match: any number of journal match strings
16 : * @options: an object containing further options
17 : *
18 : * Load and (by default) stream journal entries as
19 : * json objects. This function returns a jQuery deferred
20 : * object which delivers the various journal entries.
21 : *
22 : * The various @match strings are journalctl matches.
23 : * Zero, one or more can be specified. They must be in
24 : * string format, or arrays of strings.
25 : *
26 : * The optional @options object can contain the following:
27 : * * "count": number of entries to load and/or pre-stream.
28 : * Default is 10
29 : * * "follow": if set to false just load entries and don't
30 : * stream further journal data. Default is true.
31 : * * "directory": optional directory to load journal files
32 : * * "boot": when set only list entries from this specific
33 : * boot id, or if null then the current boot.
34 : * * "since": if specified list entries since the date/time
35 : * * "until": if specified list entries until the date/time
36 : * * "cursor": a cursor to start listing entries from
37 : * * "after": a cursor to start listing entries after
38 : * * "priority": if specified list entries below the specific priority, inclusive
39 : *
40 : * Returns a jQuery deferred promise. You can call these
41 : * functions on the deferred to handle the responses. Note that
42 : * there are additional non-jQuery methods.
43 : *
44 : * .done(function(entries) { }): Called when done, @entries is
45 : * an array of all journal entries loaded. If .stream()
46 : * has been invoked then @entries will be empty.
47 : * .fail(function(ex) { }): called if the operation fails
48 : * .stream(function(entries) { }): called when we receive entries
49 : * entries. Called once per batch of journal @entries,
50 : * whether following or not.
51 : * .stop(): stop following or retrieving entries.
52 : */
53 :
54 33 : journal.build_cmd = function build_cmd(/* ... */) {
55 33 : const matches = [];
56 33 : const options = { follow: true };
57 33 : for (let i = 0; i < arguments.length; i++) {
58 33 : const arg = arguments[i];
59 3 : if (typeof arg == "string") {
60 3 : matches.push(arg);
61 3 : } else if (typeof arg == "object") {
62 33 : if (arg instanceof Array) {
63 33 : matches.push.apply(matches, arg);
64 33 : } else {
65 33 : Object.assign(options, arg);
66 33 : break;
67 33 : }
68 3 : } else {
69 3 : console.warn("journal.journalctl called with invalid argument:", arg);
70 3 : }
71 33 : }
72 :
73 11 : if (options.count === undefined) {
74 11 : if (options.follow)
75 3 : options.count = 10;
76 : else
77 11 : options.count = null;
78 11 : }
79 :
80 33 : const cmd = ["journalctl", "-q"];
81 33 : if (!options.count)
82 8 : cmd.push("--no-tail");
83 : else
84 30 : cmd.push("--lines=" + options.count);
85 :
86 33 : cmd.push("--output=" + (options.output || "json"));
87 :
88 33 : if (options.directory)
89 3 : cmd.push("--directory=" + options.directory);
90 33 : if (options.boot)
91 4 : cmd.push("--boot=" + options.boot);
92 33 : else if (options.boot !== undefined)
93 3 : cmd.push("--boot");
94 33 : if (options.since)
95 11 : cmd.push("--since=" + options.since);
96 33 : if (options.until)
97 5 : cmd.push("--until=" + options.until);
98 33 : if (options.cursor)
99 11 : cmd.push("--cursor=" + options.cursor);
100 33 : if (options.after)
101 3 : cmd.push("--after=" + options.after);
102 33 : if (options.priority)
103 11 : cmd.push("--priority=" + options.priority);
104 33 : if (options.grep)
105 4 : cmd.push("--grep=" + options.grep);
106 :
107 : /* journalctl doesn't allow reverse and follow together */
108 33 : if (options.reverse)
109 11 : cmd.push("--reverse");
110 33 : else if (options.follow)
111 33 : cmd.push("--follow");
112 :
113 33 : cmd.push("--");
114 33 : cmd.push.apply(cmd, matches);
115 33 : return cmd;
116 33 : };
117 :
118 33 : journal.journalctl = function journalctl(/* ... */) {
119 33 : const cmd = journal.build_cmd.apply(null, arguments);
120 :
121 33 : const dfd = cockpit.defer();
122 33 : const promise = dfd.promise();
123 33 : let buffer = "";
124 33 : let entries = [];
125 33 : let streamers = [];
126 33 : let interval = null;
127 :
128 29 : function fire_streamers() {
129 29 : let ents;
130 29 : let i;
131 25 : if (streamers.length && entries.length > 0) {
132 25 : ents = entries;
133 25 : entries = [];
134 25 : for (i = 0; i < streamers.length; i++)
135 25 : streamers[i].apply(promise, [ents]);
136 24 : } else {
137 28 : window.clearInterval(interval);
138 28 : interval = null;
139 28 : }
140 29 : }
141 :
142 33 : const proc = cockpit.spawn(cmd, { batch: 8192, latency: 300, superuser: "try" })
143 30 : .stream(function(data) {
144 30 : if (buffer)
145 15 : data = buffer + data;
146 30 : buffer = "";
147 :
148 30 : const lines = data.split("\n");
149 30 : const last = lines.length - 1;
150 30 : lines.forEach(function(line, i) {
151 30 : if (i == last) {
152 30 : buffer = line;
153 30 : } else if (line && line.indexOf("-- ") !== 0) {
154 30 : try {
155 30 : entries.push(JSON.parse(line));
156 2 : } catch (e) {
157 2 : console.warn(e, line);
158 2 : }
159 30 : }
160 30 : });
161 :
162 30 : if (streamers.length && interval === null)
163 30 : interval = window.setInterval(fire_streamers, 300);
164 30 : })
165 10 : .done(function() {
166 10 : fire_streamers();
167 10 : dfd.resolve(entries);
168 10 : })
169 20 : .fail(function(ex) {
170 : /* The journalctl command fails when no entries are matched
171 : * so we just ignore this status code */
172 20 : if (ex.problem == "cancelled" ||
173 2 : ex.exit_status === 1) {
174 20 : fire_streamers();
175 20 : dfd.resolve(entries);
176 1 : } else {
177 1 : dfd.reject(ex);
178 1 : }
179 20 : })
180 21 : .always(function() {
181 21 : window.clearInterval(interval);
182 21 : });
183 :
184 33 : promise.stream = function stream(callback) {
185 33 : streamers.push(callback);
186 33 : return this;
187 33 : };
188 20 : promise.stop = function stop() {
189 20 : streamers = [];
190 20 : promise.stopped = true;
191 20 : proc.close("cancelled");
192 20 : };
193 33 : return promise;
194 33 : };
195 :
196 25 : journal.printable = function printable(value, key) {
197 25 : if (value === undefined || value === null)
198 2 : return _("[no data]");
199 25 : else if (typeof (value) == "string")
200 2 : return value;
201 2 : else if (value.length !== undefined && value.length <= 1000 && key == "MESSAGE")
202 2 : return new TextDecoder().decode(new Uint8Array(value));
203 2 : else {
204 2 : return _("[binary data]");
205 2 : }
206 25 : };
207 :
208 : /* Render the journal entries by passing suitable DOM elements back to
209 : the caller via the 'output_funcs'.
210 :
211 : Rendering is context aware. It will insert 'reboot' markers, for
212 : example, and collapse repeated lines. You can extend the output at
213 : the bottom and also at the top.
214 :
215 : A new renderer is created by calling 'journal.renderer' like
216 : so:
217 :
218 : const renderer = journal.renderer(funcs);
219 :
220 : You can feed new entries into the renderer by calling various
221 : methods on the returned object:
222 :
223 : - renderer.append(journal_entry)
224 : - renderer.append_flush()
225 : - renderer.prepend(journal_entry)
226 : - renderer.prepend_flush()
227 :
228 : A 'journal_entry' is one element of the result array returned by a
229 : call to 'Query' with the 'cockpit.journal_fields' as the fields to
230 : return.
231 :
232 : Calling 'append' will append the given entry to the end of the
233 : output, naturally, and 'prepend' will prepend it to the start.
234 :
235 : The output might lag behind what has been input via 'append' and
236 : 'prepend', and you need to call 'append_flush' and 'prepend_flush'
237 : respectively to ensure that the output is up-to-date. Flushing a
238 : renderer does not introduce discontinuities into the output. You
239 : can continue to feed entries into the renderer after flushing and
240 : repeated lines will be correctly collapsed across the flush, for
241 : example.
242 :
243 : The renderer will call methods of the 'output_funcs' object to
244 : produce the desired output:
245 :
246 : - output_funcs.append(rendered)
247 : - output_funcs.remove_last()
248 : - output_funcs.prepend(rendered)
249 : - output_funcs.remove_first()
250 :
251 : The 'rendered' argument is the return value of one of the rendering
252 : functions described below. The 'append' and 'prepend' methods
253 : should add this element to the output, naturally, and 'remove_last'
254 : and 'remove_first' should remove the indicated element.
255 :
256 : If you never call 'prepend' on the renderer, 'output_func.prepend'
257 : isn't called either. If you never call 'renderer.prepend' after
258 : 'renderer.prepend_flush', then 'output_func.remove_first' will
259 : never be called. The same guarantees exist for the 'append' family
260 : of functions.
261 :
262 : The actual rendering is also done by calling methods on
263 : 'output_funcs':
264 :
265 : - output_funcs.render_line(ident, prio, message, count, time, cursor)
266 : - output_funcs.render_day_header(day)
267 : - output_funcs.render_reboot_separator()
268 : */
269 :
270 33 : journal.renderer = function renderer(output_funcs) {
271 33 : if (!output_funcs.render_line)
272 3 : console.error("Invalid renderer provided");
273 :
274 18 : function copy_object(o) {
275 18 : const c = { }; for (const p in o) c[p] = o[p]; return c;
276 18 : }
277 :
278 : // A 'entry' object describes a journal entry in formatted form.
279 : // It has fields 'bootid', 'ident', 'prio', 'message', 'time',
280 : // 'day', all of which are strings.
281 :
282 25 : function format_entry(journal_entry) {
283 25 : const d = journal_entry.__REALTIME_TIMESTAMP / 1000; // timestamps are in µs
284 25 : return {
285 25 : cursor: journal_entry.__CURSOR,
286 25 : full: journal_entry,
287 25 : day: timeformat.date(d),
288 25 : time: timeformat.time(d),
289 25 : bootid: journal_entry._BOOT_ID,
290 1 : ident: journal_entry.SYSLOG_IDENTIFIER || journal_entry._COMM,
291 25 : prio: journal_entry.PRIORITY,
292 25 : message: journal.printable(journal_entry.MESSAGE, "MESSAGE")
293 25 : };
294 25 : }
295 :
296 25 : function entry_is_equal(a, b) {
297 18 : return (a && b &&
298 18 : a.day == b.day &&
299 18 : a.bootid == b.bootid &&
300 18 : a.ident == b.ident &&
301 17 : a.prio == b.prio &&
302 15 : a.message == b.message);
303 25 : }
304 :
305 : // A state object describes a line that should be eventually
306 : // output. It has an 'entry' field as per description above, and
307 : // also 'count', 'last_time', and 'first_time', which record
308 : // repeated entries. Additionally:
309 : //
310 : // line_present: When true, the line has been output already with
311 : // some preliminary data. It needs to be removed before
312 : // outputting more recent data.
313 : //
314 : // header_present: The day header has been output preliminarily
315 : // before the actual log lines. It needs to be removed before
316 : // prepending more lines. If both line_present and
317 : // header_present are true, then the header comes first in the
318 : // output, followed by the line.
319 :
320 25 : function render_state_line(state) {
321 25 : return output_funcs.render_line(state.entry.ident,
322 25 : state.entry.prio,
323 25 : state.entry.message,
324 25 : state.count,
325 25 : state.last_time,
326 25 : state.entry.full);
327 25 : }
328 :
329 : // We keep the state of the first and last journal lines,
330 : // respectively, in order to collapse repeated lines, and to
331 : // insert reboot markers and day headers.
332 : //
333 : // Normally, there are two state objects, but if only a single
334 : // line has been output so far, top_state and bottom_state point
335 : // to the same object.
336 :
337 33 : let top_state;
338 33 : let bottom_state;
339 :
340 33 : top_state = bottom_state = { };
341 :
342 25 : function start_new_line() {
343 : // If we now have two lines, split the state
344 18 : if (top_state === bottom_state && top_state.entry) {
345 18 : top_state = copy_object(bottom_state);
346 18 : }
347 25 : }
348 :
349 23 : function top_output() {
350 13 : if (top_state.header_present) {
351 13 : output_funcs.remove_first();
352 13 : top_state.header_present = false;
353 13 : }
354 13 : if (top_state.line_present) {
355 13 : output_funcs.remove_first();
356 13 : top_state.line_present = false;
357 13 : }
358 23 : if (top_state.entry) {
359 23 : output_funcs.prepend(render_state_line(top_state));
360 23 : top_state.line_present = true;
361 23 : }
362 23 : }
363 :
364 18 : function prepend(journal_entry) {
365 18 : const entry = format_entry(journal_entry);
366 :
367 1 : if (entry_is_equal(top_state.entry, entry)) {
368 1 : top_state.count += 1;
369 1 : top_state.first_time = entry.time;
370 1 : } else {
371 18 : top_output();
372 :
373 11 : if (top_state.entry) {
374 11 : if (entry.bootid != top_state.entry.bootid)
375 0 : output_funcs.prepend(output_funcs.render_reboot_separator());
376 11 : if (entry.day != top_state.entry.day)
377 0 : output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day));
378 11 : }
379 :
380 18 : start_new_line();
381 18 : top_state.entry = entry;
382 18 : top_state.count = 1;
383 18 : top_state.first_time = top_state.last_time = entry.time;
384 18 : top_state.line_present = false;
385 18 : }
386 18 : }
387 :
388 23 : function prepend_flush() {
389 23 : top_output();
390 23 : if (top_state.entry) {
391 23 : output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day));
392 23 : top_state.header_present = true;
393 23 : }
394 23 : }
395 :
396 9 : function bottom_output() {
397 5 : if (bottom_state.line_present) {
398 5 : output_funcs.remove_last();
399 5 : bottom_state.line_present = false;
400 5 : }
401 9 : if (bottom_state.entry) {
402 9 : output_funcs.append(render_state_line(bottom_state));
403 9 : bottom_state.line_present = true;
404 9 : }
405 9 : }
406 :
407 9 : function append(journal_entry) {
408 9 : const entry = format_entry(journal_entry);
409 :
410 5 : if (entry_is_equal(bottom_state.entry, entry)) {
411 5 : bottom_state.count += 1;
412 5 : bottom_state.last_time = entry.time;
413 5 : } else {
414 9 : bottom_output();
415 :
416 8 : if (!bottom_state.entry || entry.day != bottom_state.entry.day) {
417 9 : output_funcs.append(output_funcs.render_day_header(entry.day));
418 9 : bottom_state.header_present = true;
419 9 : }
420 8 : if (bottom_state.entry && entry.bootid != bottom_state.entry.bootid)
421 1 : output_funcs.append(output_funcs.render_reboot_separator());
422 :
423 9 : start_new_line();
424 9 : bottom_state.entry = entry;
425 9 : bottom_state.count = 1;
426 9 : bottom_state.first_time = bottom_state.last_time = entry.time;
427 9 : bottom_state.line_present = false;
428 9 : }
429 9 : }
430 :
431 9 : function append_flush() {
432 9 : bottom_output();
433 9 : }
434 :
435 33 : return {
436 33 : prepend,
437 33 : prepend_flush,
438 33 : append,
439 33 : append_flush
440 33 : };
441 33 : };
|